diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c769079f27a3..f48191e0e033 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2164,6 +2164,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', @@ -5067,6 +5068,8 @@ const CONST = { MAKE_MEMBER: 'makeMember', MAKE_ADMIN: 'makeAdmin', CHANGE_ROLE: 'changeRole', + CHANGE_OFFICE_LOCATION: 'changeOfficeLocation', + CHANGE_WORK_ARRANGEMENT: 'changeWorkArrangement', }, BULK_ACTION_TYPES: { DELETE: 'delete', @@ -5223,6 +5226,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 49861ed9bb75..2643a0cb5f1f 100644 --- a/src/CONST/runtimeConfigured.ts +++ b/src/CONST/runtimeConfigured.ts @@ -75,6 +75,8 @@ const CONST_RUNTIME: ConstRuntime = { SCREENS.SUBMIT_PLAN_WELCOME_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.WORKSPACE.MEMBERS_ROLE, SCREENS.PRE_MOUNT_BUFFER, ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION), diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 154e1a396b59..48144da90e72 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -562,6 +562,8 @@ 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 workspace members selected for the bulk role change, with the workspace they were selected on */ RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE: 'workspaceMembersSelectedForRoleChange', @@ -1901,6 +1903,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]: OnyxTypes.WorkspaceMembersSelectedForBulkEdit; [ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE]: OnyxTypes.WorkspaceMembersSelectedForRoleChange; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION_SOURCE]: string; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 907e4e98a20f..7100763e297f 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3574,6 +3574,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, @@ -3890,6 +3898,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 e98c70277973..c1da1aad7140 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -868,6 +868,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', @@ -967,6 +969,7 @@ const SCREENS = { MEMBER_DETAILS_ROLE: 'Workspace_Member_Details_Role', MEMBERS_ROLE: 'Workspace_Members_Role', MEMBER_WORK_ARRANGEMENT: 'Workspace_Member_Work_Arrangement', + MEMBER_OFFICE_LOCATION: 'Workspace_Member_Office_Location', INVITE_WORK_ARRANGEMENT: 'Workspace_Invite_Work_Arrangement', MEMBER_CUSTOM_FIELD: 'Workspace_Member_Custom_Field', MEMBER_NEW_CARD: 'Workspace_Member_NewCard', diff --git a/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx b/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx index 90703f253a56..d0caf7e373bf 100644 --- a/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx +++ b/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx @@ -34,6 +34,8 @@ 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; /** Whether the bank account column is visible on web screens or not */ shouldShowBankAccountColumn: boolean; @@ -46,6 +48,7 @@ export default function WorkspaceMembersTableRow({ rowIndex, shouldShowCustomField1Column, shouldShowCustomField2Column, + shouldShowOfficeLocationColumn, shouldShowBankAccountColumn, shouldUseNarrowTableLayout, policy, @@ -114,6 +117,22 @@ export default function WorkspaceMembersTableRow({ + {!shouldUseNarrowTableLayout && shouldShowOfficeLocationColumn && ( + + {!!item.officeLocationName && ( + + )} + + )} + {!shouldUseNarrowTableLayout && shouldShowCustomField1Column && ( ; employeeUserID?: string; employeePayrollID?: string; + officeLocationName?: string; bankAccountLastFour?: string; name: string; email: string; @@ -57,6 +58,7 @@ type WorkspaceMembersTableProps = { selectedKeys: string[]; shouldShowCustomField1Column: boolean; shouldShowCustomField2Column: boolean; + shouldShowOfficeLocationColumn: boolean; shouldShowBankAccountColumn: boolean; onRowSelectionChange: (selectedRowKeys: string[]) => void; headerComponent?: React.ReactElement; @@ -84,6 +86,7 @@ export default function WorkspaceMembersTable({ selectedKeys, shouldShowCustomField1Column, shouldShowCustomField2Column, + shouldShowOfficeLocationColumn, shouldShowBankAccountColumn, members, onRowSelectionChange, @@ -112,6 +115,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 ? [ { @@ -186,6 +201,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); } @@ -324,6 +343,7 @@ export default function WorkspaceMembersTable({ shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} shouldShowCustomField1Column={shouldShowCustomField1Column} shouldShowCustomField2Column={shouldShowCustomField2Column} + shouldShowOfficeLocationColumn={shouldShowOfficeLocationColumn} shouldShowBankAccountColumn={shouldShowBankAccountColumn} policy={policy} /> diff --git a/src/languages/de.ts b/src/languages/de.ts index 04fab8db3589..0cd53a8fa677 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7379,6 +7379,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', changeRole: 'Rolle ändern', }, card: { @@ -9881,6 +9885,11 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc setAsPrimary: ({name}: {name: string}) => `Legen Sie den Bürositz „${name}“ als primär fest`, 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} in ${officeLocation} geändert (zuvor ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `„${name}“`, + 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 1effc2a698aa..feaa613f961f 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7507,6 +7507,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ optionNoRegularWorkspaceHelp: 'Το μέλος εργάζεται από το σπίτι ή ταξιδεύει μεταξύ τοποθεσιών χωρίς τακτικό γραφείο, επομένως οι κανόνες μετακίνησης δεν ισχύουν.', futureOnlyNote: 'Οι αλλαγές ισχύουν μόνο για μελλοντικούς υπολογισμούς χιλιομέτρων. Οι υπάρχουσες δαπάνες χιλιομέτρων δεν επανυπολογίζονται.', }, + officeLocation: 'Τοποθεσία γραφείου', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Αλλαγή τοποθεσίας γραφείου', + changeWorkArrangement: 'Αλλαγή ρύθμισης εργασίας', changeRole: 'Αλλαγή ρόλου', }, card: { @@ -10006,6 +10010,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 77fec2e2bbab..ea76e991d407 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7706,6 +7706,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', @@ -10034,6 +10038,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 67f6933ea2a0..1919fa94b5d8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7237,6 +7237,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 la oficina', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Cambiar ubicación de la oficina', + changeWorkArrangement: 'Cambiar modalidad de trabajo', }, accounting: { settings: 'configuración', @@ -9642,6 +9646,11 @@ ${reportName}`, setAsPrimary: ({name}: {name: string}) => `establecer la ubicación de la oficina «${name}» como principal`, 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 la oficina de ${displayName} a ${officeLocation} (previamente ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `"${name}"`, + 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 0885acea6f58..c90d868c8fc4 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7398,6 +7398,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 bureau', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Modifier le lieu de travail', + changeWorkArrangement: 'Modifier l’organisation du travail', changeRole: 'Modifier le rôle', }, card: { @@ -9904,6 +9908,11 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e setAsPrimary: ({name}: {name: string}) => `définir le lieu de travail « ${name} » comme principal`, 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 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 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 45bffa78d3b0..90811faa21b9 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7336,6 +7336,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 ufficio', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Cambia sede dell’ufficio', + changeWorkArrangement: 'Modifica modalità di lavoro', changeRole: 'Cambia ruolo', }, card: { @@ -9824,6 +9828,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, setAsPrimary: ({name}: {name: string}) => `imposta la sede "${name}" come principale`, 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 cambiato la sede di ${displayName} in ${officeLocation} (in precedenza ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `"${name}"`, + companyAddress: 'l’indirizzo dell’azienda', + 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 ba614e4577ed..b5440b3a55bb 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7245,6 +7245,10 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から optionNoRegularWorkspaceHelp: 'メンバーは在宅勤務、または決まったオフィスがなく拠点間を移動しているため、通勤規程は適用されません。', futureOnlyNote: '変更は今後の走行距離計算にのみ適用されます。既存の走行距離経費は再計算されません。', }, + officeLocation: 'オフィス所在地', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name}(${address})`, + changeOfficeLocation: 'オフィスの所在地を変更', + changeWorkArrangement: '勤務形態を変更', changeRole: 'ロールを変更', }, card: { @@ -9688,6 +9692,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 ff7257d04879..5c62941980ef 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7319,6 +7319,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: 'Locatie kantoor wijzigen', + changeWorkArrangement: 'Werkregeling wijzigen', changeRole: 'Rol wijzigen', }, card: { @@ -9800,6 +9804,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, setAsPrimary: ({name}: {name: string}) => `stel de kantoorlocatie "${name}" in als primair`, 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 kantoorlokatie van ${displayName} gewijzigd naar ${officeLocation} (voorheen ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `‘${name}’`, + 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 ddae7042d2a0..a87fcfd05525 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7345,6 +7345,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ń tryb pracy', changeRole: 'Zmień rolę', }, card: { @@ -9811,6 +9815,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, setAsPrimary: ({name}: {name: string}) => `ustaw lokalizację biura „${name}” jako główną`, 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} (poprzednio ${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 9087c58c1d7e..e073615e4d12 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7321,6 +7321,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 do escritório', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Alterar local do escritório', + changeWorkArrangement: 'Alterar regime de trabalho', changeRole: 'Alterar função', }, card: { @@ -9797,6 +9801,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, setAsPrimary: ({name}: {name: string}) => `definir o local de escritório "${name}" como principal`, 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 trabalho de ${displayName} para ${officeLocation} (anteriormente ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `"${name}"`, + companyAddress: 'o endereço da empresa', + 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 77c3b22319b6..87b5965523f7 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7065,6 +7065,10 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM optionNoRegularWorkspaceHelp: '成员在家办公或在各地点之间出差,没有固定办公地点,因此通勤规则不适用。', futureOnlyNote: '更改仅适用于未来的里程计算,现有的里程报销将不会重新计算。', }, + officeLocation: '办公室地点', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name}(${address})`, + changeOfficeLocation: '更改办公室位置', + changeWorkArrangement: '更改工作安排', changeRole: '更改角色', }, card: { @@ -9438,6 +9442,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..ab59403b7733 --- /dev/null +++ b/src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts @@ -0,0 +1,14 @@ +type UpdateEmployeeOfficeLocationParams = { + policyID: string; + + /** + * 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 */ + officeID: string; +}; + +export default UpdateEmployeeOfficeLocationParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index bc1f98d318d4..2a50ecba2d99 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -644,6 +644,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 efc8136c15b4..7719574713a3 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -310,6 +310,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', @@ -1262,6 +1263,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 d788566130eb..d5e71c0e3af1 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -530,6 +530,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.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, ONYXKEYS.RECENTLY_USED_CURRENCIES, ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 814d0b8e6453..dd6d600dca96 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -560,6 +560,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, @@ -616,6 +618,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/members/WorkspaceMemberDetailsRolePage').default, [SCREENS.WORKSPACE.MEMBERS_ROLE]: () => require('../../../../pages/workspace/members/WorkspaceMembersRolePage').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.INVITE_WORK_ARRANGEMENT]: () => require('../../../../pages/workspace/members/WorkArrangementPage').default, [SCREENS.WORKSPACE.MEMBER_CUSTOM_FIELD]: () => require('../../../../pages/workspace/members/WorkspaceMemberCustomFieldPage').default, [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK]: () => require('@pages/workspace/members/DynamicWorkspaceOwnerChangeWrapperPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index bdb9cf2231ce..15ce62c1bd74 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -23,11 +23,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, }, @@ -1181,6 +1187,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.INVITE_WORK_ARRANGEMENT]: { path: DYNAMIC_ROUTES.WORKSPACE_INVITE_WORK_ARRANGEMENT.path, }, diff --git a/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts index a697c894a1db..dc38564d79a3 100644 --- a/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts +++ b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts @@ -47,6 +47,12 @@ type WorkspaceSettingsParamList = { [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; }; @@ -442,6 +448,10 @@ type WorkspaceSettingsParamList = { policyID: string; accountID: string; }; + [SCREENS.WORKSPACE.MEMBER_OFFICE_LOCATION]: { + policyID: string; + accountID: string; + }; [SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT]: { policyID: string; }; diff --git a/src/libs/OfficeLocationUtils.ts b/src/libs/OfficeLocationUtils.ts new file mode 100644 index 000000000000..ef6a9b8b3adb --- /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 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 hasCompanyAddress(policy) || 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 unless an office location is set as primary + 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}; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 454d68d2da66..e47d9ce19b28 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4865,6 +4865,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) ?? {}; @@ -5704,6 +5726,7 @@ export { getAddedOfficeLocationMessage, getUpdatedOfficeLocationMessage, getDeletedOfficeLocationMessage, + getUpdatedEmployeeOfficeLocationMessage, getWorkspaceTagUpdateMessage, getWorkspaceReportFieldUpdateMessage, getWorkspaceReportFieldDeleteMessage, diff --git a/src/libs/ReportAlternateTextUtils.ts b/src/libs/ReportAlternateTextUtils.ts index 91755f803d6a..03c34d15809d 100644 --- a/src/libs/ReportAlternateTextUtils.ts +++ b/src/libs/ReportAlternateTextUtils.ts @@ -136,6 +136,7 @@ import { getUpdatedCardFeedLiabilityMessage, getUpdatedCardFeedStatementPeriodMessage, getUpdatedDefaultTitleMessage, + getUpdatedEmployeeOfficeLocationMessage, getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, getUpdatedMemberWorkArrangementMessage, @@ -877,6 +878,9 @@ function getLastMessageTextForReport({ if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION)) { lastMessageTextFromReport = getDeletedOfficeLocationMessage(translate, lastReportAction); } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION)) { + lastMessageTextFromReport = getUpdatedEmployeeOfficeLocationMessage(translate, lastReportAction); + } if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT)) { lastMessageTextFromReport = getAutoReimbursementMessage(translate, lastReportAction, convertToDisplayString); } diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index 921ba7e15fc0..baaf46de3ca4 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -44,6 +44,7 @@ import { getActionableCardFraudAlertResolutionMessage, getAgentPromptUpdatedMessage, getAddedCardFeedMessage, + getAddedOfficeLocationMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, getAutoCategorizeNewExpensesMessage, @@ -64,6 +65,7 @@ import { getCustomTaxNameUpdateMessage, getDefaultApproverUpdateMessage, getDelegateSubmitMessage, + getDeletedOfficeLocationMessage, getDismissedViolationMessageText, getDynamicExternalWorkflowApproveFailedActionMessage, getDynamicExternalWorkflowSubmitFailedActionMessage, @@ -91,6 +93,7 @@ import { getPolicyChangeLogMaxExpenseAmountMessage, getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, + getPolicyWorkArrangementMessage, getReimburserUpdateMessage, getRemovedCardFeedMessage, getRenamedAction, @@ -109,6 +112,9 @@ import { getUpdatedAutoHarvestingMessage, getUpdatedCardFeedLiabilityMessage, getUpdatedCardFeedStatementPeriodMessage, + getUpdatedEmployeeOfficeLocationMessage, + getUpdatedMemberWorkArrangementMessage, + getUpdatedOfficeLocationMessage, getUpdatedProhibitedExpensesMessage, getWorkspaceAttendeeTrackingUpdateMessage, getWorkspaceCategoryUpdateMessage, @@ -703,6 +709,24 @@ function computeReportNameBasedOnReportAction({ if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_HARVESTING) { return getUpdatedAutoHarvestingMessage(translate, parentReportAction); } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_POLICY_WORK_ARRANGEMENT) { + return getPolicyWorkArrangementMessage(translate, parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT) { + return getUpdatedMemberWorkArrangementMessage(translate, parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION) { + return getAddedOfficeLocationMessage(translate, parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION) { + return getUpdatedOfficeLocationMessage(translate, parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION) { + return getDeletedOfficeLocationMessage(translate, parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION) { + return getUpdatedEmployeeOfficeLocationMessage(translate, parentReportAction); + } if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_REPORT_FIELD) { return getWorkspaceReportFieldAddMessage(translate, parentReportAction); } diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 071f9c2da4a2..8f84878638b4 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -1,5 +1,3 @@ -import type {LocalizedTranslate} from '@components/LocaleContextProvider'; - import * as API from '@libs/API'; import type { AddOfficeLocationParams, @@ -16,23 +14,21 @@ import type { SetPolicyRequireMapOrGPSParams, SetPolicyWorkArrangementParams, SetWorkspaceDistanceAutoUpdateParams, + UpdateEmployeeOfficeLocationParams, UpdateOfficeLocationParams, UpdatePolicyDistanceRateParams, UpdatePolicyDistanceRateValueParams, } from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import {generateHexadecimalValue, rand64} from '@libs/NumberUtils'; +import {generateHexadecimalValue} from '@libs/NumberUtils'; import {buildOnyxDataForGovernmentRateAutoUpdate, buildOnyxDataForPolicyDistanceRateUpdates} from '@libs/PolicyDistanceRatesUtils'; import {goBackWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils'; -import {getRoom} from '@libs/ReportUtils'; -import {getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {GovernmentMileageRate, PersonalDetailsList, Policy, PolicyEmployee, ReportAction, TransactionViolation} from '@src/types/onyx'; +import type {GovernmentMileageRate, PersonalDetailsList, Policy, PolicyEmployee, TransactionViolation} from '@src/types/onyx'; import type {ErrorFields, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {CommuterExclusions, CompanyAddress, CustomUnit, OfficeLocation, Rate} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -730,38 +726,27 @@ function disablePolicyCommuterExclusions(policyID: string, previousCommuterExclu type WorkArrangementMemberUpdate = { /** 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. */ + /** The member's login, used as the employeeList key. */ email: string; - /** The member's display name, used in the changelog message. */ - name: string; /** The member's previous office arrangement, used to restore it if the update fails. */ previousHasOfficeWorkArrangement: boolean | undefined; /** The member may still be an optimistic invite, so changing the arrangement must not clear its pending action. */ previousPendingAction: PolicyEmployee['pendingAction']; - /** The ID assigned to this member's optimistic changelog action. */ - optimisticReportActionID: string; }; /** * Set the work arrangement (office-based or no regular workspace) for one or more policy members. * The single command covers both individual and bulk updates: employeeAccountIDList carries every - * affected accountID, and one optimistic member work arrangement changelog action is - * created per member in the workspace admins room. + * affected accountID. The server logs each change in the workspace admins room, where it can land in a thread, so no + * change log is added optimistically. */ -function setEmployeeWorkArrangement( - policy: OnyxEntry, - employeeAccountIDList: number[], - isOffice: boolean, - personalDetails: OnyxEntry, - translate: LocalizedTranslate, -) { +function setEmployeeWorkArrangement(policy: OnyxEntry, employeeAccountIDList: number[], isOffice: boolean, personalDetails: OnyxEntry) { const policyID = policy?.id; if (!policyID) { return; } const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; - const newLabel = getWorkArrangementLabel(translate, isOffice); const updates: WorkArrangementMemberUpdate[] = []; for (const accountID of employeeAccountIDList) { const personalDetail = personalDetails?.[accountID]; @@ -780,10 +765,8 @@ function setEmployeeWorkArrangement( updates.push({ accountID, email: login, - name: personalDetail?.displayName ?? login, previousHasOfficeWorkArrangement, previousPendingAction: employee.pendingAction, - optimisticReportActionID: rand64(), }); } @@ -791,7 +774,6 @@ function setEmployeeWorkArrangement( return; } - const created = DateUtils.getDBTime(); const employeeListOptimisticUpdate: Record> = {}; const employeeListSuccessUpdate: Record> = {}; const employeeListFailureUpdate: Record> = {}; @@ -810,21 +792,21 @@ function setEmployeeWorkArrangement( }; } - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: policyKey, value: {employeeList: employeeListOptimisticUpdate}, }, ]; - const successData: Array> = [ + const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: policyKey, value: {employeeList: employeeListSuccessUpdate}, }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: policyKey, @@ -832,55 +814,106 @@ function setEmployeeWorkArrangement( }, ]; - 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 previousLabel = getWorkArrangementLabel(translate, update.previousHasOfficeWorkArrangement ?? false); - const text = translate('workspaceActions.updatedMemberWorkArrangement', {displayName: update.name, newArrangement: newLabel, oldArrangement: previousLabel}); - optimisticReportActions[update.optimisticReportActionID] = { - reportActionID: update.optimisticReportActionID, - actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT, - created, - shouldShow: true, - automatic: false, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - message: [ - { - type: CONST.REPORT.MESSAGE.TYPE.COMMENT, - html: `${text}`, - text, - }, - ], - originalMessage: { - accountID: update.accountID, - email: update.email, - name: update.name, - newValue: isOffice, - oldValue: update.previousHasOfficeWorkArrangement ?? false, - }, - }; - 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: SetEmployeeWorkArrangementParams = { policyID, employeeAccountIDList: updates.map((update) => update.accountID).join(','), employeeLoginList: updates.map((update) => update.email).join(','), isOffice, }; - const onyxData: OnyxData = {optimisticData, successData, failureData}; + const onyxData: OnyxData = {optimisticData, successData, failureData}; API.write(WRITE_COMMANDS.SET_EMPLOYEE_WORK_ARRANGEMENT, parameters, onyxData); } +type OfficeLocationMemberUpdate = { + /** The member's login, used as the employeeList key and to match the member on the server. */ + email: 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']; +}; + +/** + * 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: + * employeeLoginList carries the login of every affected member. The server logs each change in the workspace admins + * room, where it can land in a thread, so no change log is added optimistically. + */ +function updateEmployeeOfficeLocation(policy: OnyxEntry, employeeAccountIDList: number[], officeID: string, personalDetails: OnyxEntry) { + 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({ + email: login, + previousOfficeID: employee.officeID, + previousPendingAction: employee.pendingAction, + }); + } + + if (updates.length === 0) { + return; + } + + const employeeListOptimisticUpdate: Record> = {}; + const employeeListSuccessUpdate: Record> = {}; + const employeeListFailureUpdate: Record> = {}; + for (const update of updates) { + 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, + ...(!update.previousPendingAction && {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 parameters: UpdateEmployeeOfficeLocationParams = { + policyID, + 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` @@ -1189,6 +1222,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 5951a78e5e22..a54d0e330422 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1568,6 +1568,14 @@ function clearImportedSpreadsheetMemberData() { Onyx.set(ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_ROLE, null); } +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, null); +} + export { removeMembers, buildUpdateWorkspaceMembersRoleOnyxData, @@ -1603,5 +1611,7 @@ export { setImportedSpreadsheetMemberData, setImportedSpreadsheetMemberRole, clearImportedSpreadsheetMemberData, + setWorkspaceMembersSelectedForBulkEdit, + clearWorkspaceMembersSelectedForBulkEdit, updatePolicyMemberDisplayName, }; diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index f14c4e1c9e4a..54281c8b4210 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -90,6 +90,7 @@ import { getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getPolicyChangeLogUpdateEmployee, + getPolicyWorkArrangementMessage, getReimbursedMessage, getReimburserUpdateMessage, getRemovedCardFeedMessage, @@ -118,6 +119,7 @@ import { getUpdatedCardFeedLiabilityMessage, getUpdatedCardFeedStatementPeriodMessage, getUpdatedDefaultTitleMessage, + getUpdatedEmployeeOfficeLocationMessage, getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, getUpdatedMemberWorkArrangementMessage, @@ -1495,6 +1497,8 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(getUpdatedDefaultTitleMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_HARVESTING)) { setClipboardMessage(getUpdatedAutoHarvestingMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_POLICY_WORK_ARRANGEMENT)) { + setClipboardMessage(getPolicyWorkArrangementMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT)) { setClipboardMessage(getUpdatedMemberWorkArrangementMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION)) { @@ -1503,6 +1507,8 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(getUpdatedOfficeLocationMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION)) { setClipboardMessage(getDeletedOfficeLocationMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION)) { + setClipboardMessage(getUpdatedEmployeeOfficeLocationMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INDIVIDUAL_BUDGET_NOTIFICATION)) { setClipboardMessage(getUpdatedIndividualBudgetNotificationMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SHARED_BUDGET_NOTIFICATION)) { diff --git a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx index 946ab28ed808..4ee94cf93cc7 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, @@ -245,6 +246,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 a68a94180fc0..18805ee36440 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'; @@ -44,6 +45,7 @@ import { downloadMembersCSV, removeMembers, setMembersSelectedForRoleChange, + setWorkspaceMembersSelectedForBulkEdit, } from '@libs/actions/Policy/Member'; import {removeApprovalWorkflow as removeApprovalWorkflowAction, updateApprovalWorkflow} from '@libs/actions/Workflow'; import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; @@ -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'; @@ -87,6 +90,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 {PendingAction} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -116,7 +120,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', 'Plus', 'RemoveMembers', 'Sync', 'Table', 'UserPencil']); + const icons = useMemoizedLazyExpensifyIcons(['Building', 'Download', 'FallbackAvatar', 'Location', 'Plus', 'RemoveMembers', 'Sync', 'Table', 'UserPencil']); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); const policyMemberEmailsToAccountIDs = useMemo( () => getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails, true), @@ -133,6 +137,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: (selection) => selection?.accountIDs.length}); + + // 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) { + setPrevMembersSelectedForBulkEditCount(membersSelectedForBulkEditCount); + if (!membersSelectedForBulkEditCount) { + setSelectedEmployees([]); + } + } const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const {showDownloadErrorModal} = useAlertModals(); @@ -364,6 +380,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_ARRANGEMENTS) && hasOfficeLocations(policy); + const shouldShowOfficeLocationColumn = !shouldUseNarrowLayout && shouldShowOfficeLocations; + 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. @@ -408,6 +427,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers email: memberEmail, employeeUserID: policyEmployee.employeeUserID, employeePayrollID: policyEmployee.employeePayrollID, + officeLocationName: shouldShowOfficeLocationColumn ? getMemberOfficeLocation(officeLocations, policyEmployee.officeID)?.name : undefined, bankAccountLastFour: memberBankAccounts?.[accountID]?.bankAccountLastFour, isInteractive: !details.isOptimisticPersonalDetail, isSelectionDisabled: @@ -442,6 +462,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers session?.accountID, shouldShowCustomField1Column, shouldShowCustomField2Column, + shouldShowOfficeLocationColumn, + officeLocations, memberBankAccounts, invitedPrimaryToSecondaryLogins, openMemberDetails, @@ -547,6 +569,14 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers } } + const editSelectedEmployees = (bulkEditRoute: Route) => { + setWorkspaceMembersSelectedForBulkEdit( + policyID, + selectedEmployees.map((login) => policyMemberEmailsToAccountIDs[login]).filter((accountID) => accountID !== undefined), + ); + Navigation.navigate(bulkEditRoute); + }; + const getBulkActionsButtonOptions = () => { const selectedEmployeesRoles = selectedEmployees.map((email) => { return policy?.employeeList?.[email]?.role; @@ -572,6 +602,28 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers }); } + if (isPaidGroupPolicy(policy) && shouldShowOfficeLocations) { + 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 ( + isPaidGroupPolicy(policy) && + 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, + icon: icons.Building, + onSelected: () => editSelectedEmployees(ROUTES.WORKSPACE_MEMBERS_CHANGE_WORK_ARRANGEMENT.getRoute(policyID)), + }); + } + if (canManageSelectedEmployees) { options.push({ text: translate('workspace.people.removeMembersTitle', {count: selectedEmployees.length}), @@ -783,6 +835,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers selectedKeys={selectedEmployees} shouldShowCustomField1Column={shouldShowCustomField1Column} shouldShowCustomField2Column={shouldShowCustomField2Column} + shouldShowOfficeLocationColumn={shouldShowOfficeLocationColumn} shouldShowBankAccountColumn={shouldShowBankAccountColumn} onRowSelectionChange={setSelectedEmployees} headerComponent={tableHeaderComponent} 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/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 && } diff --git a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx index 58c3519b20f3..92f21755f438 100644 --- a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx @@ -23,7 +23,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {getDistanceRateCustomUnit, hasCompanyAddress, hasOfficeWorkArrangement} from '@libs/PolicyUtils'; +import {hasOfficeLocations} from '@libs/OfficeLocationUtils'; +import {getDistanceRateCustomUnit, hasOfficeWorkArrangement} from '@libs/PolicyUtils'; import {getUnitTranslationKey} from '@libs/WorkspaceDisplayUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -68,7 +69,7 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps selector: (policy) => ({ commuterExclusions: policy?.commuterExclusions, unit: getDistanceRateCustomUnit(policy)?.attributes?.unit, - hasWorkspaceAddress: hasCompanyAddress(policy), + hasOfficeLocations: hasOfficeLocations(policy), pendingFields: policy?.pendingFields, errorFields: policy?.errorFields, }), @@ -103,7 +104,7 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps if (item.keyForList === selectedKey) { return; } - if (item.keyForList === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !policyData?.hasWorkspaceAddress) { + if (item.keyForList === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !policyData?.hasOfficeLocations) { confirmModal.showConfirmModal({ title: translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.title'), prompt: ( @@ -152,7 +153,7 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps } if (selectedKey === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE) { - if (!policyData?.hasWorkspaceAddress) { + if (!policyData?.hasOfficeLocations) { return; } if (existingMethod === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE) { diff --git a/src/pages/workspace/members/WorkArrangementPage.tsx b/src/pages/workspace/members/WorkArrangementPage.tsx index 716345878d0f..acabcfe78a56 100644 --- a/src/pages/workspace/members/WorkArrangementPage.tsx +++ b/src/pages/workspace/members/WorkArrangementPage.tsx @@ -97,7 +97,7 @@ function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPa navigateBackToDetails(); return; } - setEmployeeWorkArrangement(policy, [memberAccountID], value, personalDetails, translate); + setEmployeeWorkArrangement(policy, [memberAccountID], value, personalDetails); navigateBackToDetails(); }; diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index 46744de85695..ff2caf952955 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -27,6 +27,7 @@ import {setWorkspaceInviteMessageDraft} from '@libs/actions/Policy/Policy'; import {saveFastEditApprovalWorkflow} from '@libs/actions/Workflow'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; +import {getAssignableOfficeLocations, getMemberOfficeLocation} from '@libs/OfficeLocationUtils'; import {getNewAccountIDsAndLogins, getPersonalDetailsForAccountIDs, getPersonalDetailsOnyxDataForOptimisticUsers, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import { canMemberAssignElevatedRole, @@ -88,7 +89,7 @@ function WorkspaceInviteMessageComponent({ goToNextStep, }: WorkspaceInviteMessageComponentProps) { const styles = useThemeStyles(); - const {translate, formatPhoneNumber} = useLocalize(); + const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const {isBetaEnabled} = usePermissions(); const policyName = policy?.name; @@ -136,10 +137,10 @@ function WorkspaceInviteMessageComponent({ const shouldShowWorkArrangement = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; const selectedHasOfficeWorkArrangement = workspaceInviteWorkArrangementDraft ?? policy?.commuterExclusions?.isOfficeWorkArrangement ?? true; - const officeLocations = Object.values(policy?.officeLocations ?? {}); - const defaultOfficeName = officeLocations.find((office) => office.isDefault)?.name; + const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); const selectedWorkArrangementLabel = translate(selectedHasOfficeWorkArrangement ? 'workspace.people.officeBased' : 'workspace.people.noRegularWorkspace'); - const defaultOfficeLabel = officeLocations.length > 1 ? defaultOfficeName : undefined; + // Invitees have no office of their own yet, so office-based ones commute to the primary office + const defaultOfficeLabel = selectedHasOfficeWorkArrangement && officeLocations.length > 1 ? getMemberOfficeLocation(officeLocations, undefined)?.name : undefined; const isApproverValid = !!workspaceInviteApproverDraft && workspaceInviteApproverDraft in (policy?.employeeList ?? {}); const validatedApprover = isApproverValid ? workspaceInviteApproverDraft : undefined; diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 386bbdf3b1b0..1ea6bc57d14b 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -39,6 +39,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'; @@ -129,6 +130,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 ?? ''; @@ -138,7 +140,9 @@ 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_ARRANGEMENTS) && hasOfficeLocations(policy); 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]; @@ -416,6 +420,18 @@ 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)) : undefined} + testID="member-office-location-menu-item" + value={ + memberOfficeLocation + ? translate('workspace.people.officeLocationWithAddress', {name: memberOfficeLocation.name, address: memberOfficeLocation.address}) + : '' + } + /> + )} {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_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(); + const officeID = chosenOfficeID ?? currentOfficeID; + + const navigateBackToDetails = () => { + Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + }; + + const changeOfficeLocation = () => { + if (!officeID || !canChangeOfficeLocation) { + return; + } + + // 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); + } + navigateBackToDetails(); + }; + + 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(WorkspaceMemberOfficeLocationPage); diff --git a/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx new file mode 100644 index 000000000000..37083fbb5f84 --- /dev/null +++ b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx @@ -0,0 +1,139 @@ +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 {canMemberWrite} 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 [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(selectionMetadata) || !!selectedAccountIDs?.length; + 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) => ({ + 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(selectedMembers.map((member) => member.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; + } + + // 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); + } + 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..55d71de80073 --- /dev/null +++ b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx @@ -0,0 +1,133 @@ +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 {canMemberWrite} 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'; +import {View} from 'react-native'; + +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 [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(selectionMetadata) || !!selectedAccountIDs?.length; + const canChangeWorkArrangement = + isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && + canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS) && + policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; + + 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 = () => { + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)); + }; + + const changeWorkArrangement = () => { + if (isOffice === undefined || !selectedAccountIDs?.length) { + return; + } + + // 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); + } + 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 bc3574884af4..b9c8f7aa6dd2 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -47,6 +47,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.INVITE_WORK_ARRANGEMENT | typeof SCREENS.WORKSPACE.MEMBER_CUSTOM_FIELD | typeof SCREENS.WORKSPACE.INVOICES diff --git a/src/setup/index.ts b/src/setup/index.ts index 5dcd3aa3be21..027219d927df 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -105,6 +105,7 @@ export default function () { ONYXKEYS.RAM_ONLY_WISE_KYC_REVIEW_EMBEDDED_LINK, 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_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, 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 c399ac235ccb..5eafc50f3336 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -901,6 +901,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 */ @@ -1816,6 +1840,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/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 ef215f017079..df5d873d115c 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -256,6 +256,7 @@ import type WalletStatement from './WalletStatement'; import type WalletTerms from './WalletTerms'; import type WalletTransfer from './WalletTransfer'; import type {WiseKYCReviewEmbeddedLink} from './WiseKYCReview'; +import type WorkspaceMembersSelectedForBulkEdit from './WorkspaceMembersSelectedForBulkEdit'; import type WorkspaceMembersSelectedForRoleChange from './WorkspaceMembersSelectedForRoleChange'; export type { @@ -419,6 +420,7 @@ export type { TransactionViolations, TravelSettings, WorkspaceTravelSettings, + WorkspaceMembersSelectedForBulkEdit, UserLocation, UserMetadata, UserWallet, diff --git a/tests/ui/WorkArrangementPageTest.tsx b/tests/ui/WorkArrangementPageTest.tsx index 2c2a098b1be6..88dac1b21440 100644 --- a/tests/ui/WorkArrangementPageTest.tsx +++ b/tests/ui/WorkArrangementPageTest.tsx @@ -176,7 +176,7 @@ describe('WorkArrangementPage', () => { }); // Then the update is requested and the member details page is shown - expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(policy, [accountID], true, personalDetails, expect.any(Function)); + expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(policy, [accountID], true, personalDetails); expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }); @@ -284,7 +284,7 @@ describe('WorkArrangementPage', () => { }); // Then the update uses the synced account ID and the current personal details - expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(syncedPolicy, [syncedAccountID], true, syncedPersonalDetails, expect.any(Function)); + expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(syncedPolicy, [syncedAccountID], true, syncedPersonalDetails); }); it('keeps the member profile as the back destination after an offline invite syncs', async () => { diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index a0f7cf1bce9d..21a372233dd2 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -23,6 +23,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'; @@ -238,6 +239,90 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); + 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, + [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: hasCompanyAddress ? companyAddress : undefined, + officeLocations: hasOffices ? officeLocations : undefined, + employeeList, + }); + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, betaEnabled ? [CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS] : [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('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 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(); + }); + + it('hides the office location item without the commuter exclusions arrangements beta', async () => { + // Given a workspace with offices and the commuter exclusions beta, but not the arrangements beta that office assignments belong to + 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..f599afdd8d82 --- /dev/null +++ b/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx @@ -0,0 +1,167 @@ +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'; +import Navigation from '@libs/Navigation/Navigation'; +import {canMemberWrite} 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/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/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'), + canMemberWrite: 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 MockButtonProps = { + onPress?: () => void; + isDisabled?: boolean; +}; + +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 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(canMemberWrite).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 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('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 the office they follow is confirmed without choosing another one + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // 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).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + }); + + 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 + act(() => { + getSelectionListProps()?.onSelectRow?.({keyForList: 'OFFICE2'}); + }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then nothing is sent, since changing office assignments needs write access to the members + expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); + }); + + 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 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(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..7d4a783e5d66 --- /dev/null +++ b/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx @@ -0,0 +1,181 @@ +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 AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +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'), + 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); + +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([{policyID, accountIDs: [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 the selected members outside it 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 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(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}); + }); + + 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 new file mode 100644 index 000000000000..3ec6d2e050a1 --- /dev/null +++ b/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx @@ -0,0 +1,167 @@ +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 AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +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', () => ({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); + +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([{policyID, accountIDs: [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 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); + + // When office-based is chosen and confirmed + act(() => { + getSelectorProps()?.onSelect(true); + }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // 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(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}); + }); + + 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/WorkspaceMembersTableTest.tsx b/tests/ui/WorkspaceMembersTableTest.tsx index e50b67055577..261cb0474aa6 100644 --- a/tests/ui/WorkspaceMembersTableTest.tsx +++ b/tests/ui/WorkspaceMembersTableTest.tsx @@ -57,6 +57,7 @@ function renderTable(members: WorkspaceMemberRowData[], tableRef: React.RefObjec selectedKeys={[]} shouldShowCustomField1Column={false} shouldShowCustomField2Column={false} + shouldShowOfficeLocationColumn={false} shouldShowBankAccountColumn onRowSelectionChange={jest.fn()} /> diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index 0be94fe9c8c8..25b20ad8c6ad 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -12,6 +12,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 {SettingsNavigatorParamList, WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -21,7 +22,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 type {ValueOf} from 'type-fest'; @@ -661,6 +664,173 @@ describe('WorkspaceMembers', () => { }); }); + 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'); + await waitForBatchedUpdatesWithAct(); + await openBulkActions(); + }; + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, [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 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(() => {}); + 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({policyID: policy.id, accountIDs: [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, {policyID: policy.id, accountIDs: [userAccountID]}); + }); + const selectedLabel = TestHelper.translateLocal('workspace.common.selected', {count: 1}); + expect(screen.getByText(selectedLabel)).toBeOnTheScreen(); + + // 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, null); + }); + + // Then the members page drops its selection too + await waitFor(() => { + expect(screen.queryByText(selectedLabel)).not.toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should hide the office location column and bulk changes without the commuter exclusions arrangements beta', async () => { + // Given a workspace with offices, and an admin with the commuter exclusions beta but not the arrangements beta + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.COMMUTER_EXCLUSIONS]); + }); + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + + // When they open the bulk actions for a member + await openBulkActionsForMember(); + + // Then there is no office location column, and neither change is offered + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.officeLocation'))).not.toBeOnTheScreen(); + expect(screen.queryByText('New Jersey Office')).not.toBeOnTheScreen(); + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.changeOfficeLocation'))).not.toBeOnTheScreen(); + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.changeWorkArrangement'))).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + 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, + 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 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(); + }); + }); + describe('Inline role editing', () => { it('lets a just-invited member be role-edited before their account resolves', async () => { // Given a member invited with optimistic personal details, which is how a new invite stays until the 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); + }); +}); diff --git a/tests/unit/ContextMenuActionsCopyMessageTest.ts b/tests/unit/ContextMenuActionsCopyMessageTest.ts index 6fb5f972df39..1470c27421b1 100644 --- a/tests/unit/ContextMenuActionsCopyMessageTest.ts +++ b/tests/unit/ContextMenuActionsCopyMessageTest.ts @@ -256,6 +256,32 @@ describe('ContextMenuActions copy message', () => { expect(mockSetString).toHaveBeenCalledWith('updated work arrangement'); }); + it('copies the localized message for a default work arrangement update action', () => { + // Given a default work arrangement change log, whose stored text is Auth's English message + mockCanSetHtml.mockReturnValue(false); + mockGetClipboardText.mockReturnValue('changed the default work arrangement'); + + if (!copyMessageAction?.onPress) { + throw new Error('Copy message context menu action was not found'); + } + + // When its message is copied + copyMessageAction.onPress( + false, + createReportActionPayload( + createMock({ + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_POLICY_WORK_ARRANGEMENT, + message: [{text: 'stored message', html: 'stored message'}], + originalMessage: {newValue: true, oldValue: false}, + }), + ), + ); + + // Then the translated change log text is copied instead of the stored text + expect(mockGetClipboardText).toHaveBeenCalledWith('workspaceActions.workArrangement.changed', undefined); + expect(mockSetString).toHaveBeenCalledWith('changed the default work arrangement'); + }); + it.each([ [ CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION, @@ -268,6 +294,11 @@ describe('ContextMenuActions copy message', () => { 'workspaceActions.officeLocation.renamed', ], [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION, {officeID: 'OFFICE1', name: 'Headquarters'}, 'workspaceActions.officeLocation.removed'], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION, + {accountID: 1, email: 'member@test.com', name: 'Member One', officeID: 'OFFICE2', officeName: 'New Jersey Office', oldOfficeID: '', oldOfficeName: ''}, + 'workspaceActions.officeLocation.changedMemberOfficeLocation', + ], ] as const)('copies the localized message for a %s action', (actionName, originalMessage, translationKey) => { // Given an office location change log, whose stored text is Auth's English message mockCanSetHtml.mockReturnValue(false); diff --git a/tests/unit/DistanceRateTest.ts b/tests/unit/DistanceRateTest.ts index 24edfd528f54..d3e6cce00ce3 100644 --- a/tests/unit/DistanceRateTest.ts +++ b/tests/unit/DistanceRateTest.ts @@ -1,11 +1,10 @@ -import type {LocalizedTranslate} from '@components/LocaleContextProvider'; - import { deletePolicyDistanceRates, enablePolicyDistanceRates, setEmployeeWorkArrangement, setPolicyDistanceRatesUnit, setWorkspaceDistanceAutoUpdate, + updateEmployeeOfficeLocation, } from '@libs/actions/Policy/DistanceRate'; import * as API from '@libs/API'; import {pause, resetQueue} from '@libs/Network/SequentialQueue'; @@ -13,9 +12,10 @@ import {isGovernmentRateUnmodified} from '@libs/PolicyDistanceRatesUtils'; import {getEffectiveWorkArrangement} from '@libs/WorkArrangementUtils'; 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'; @@ -24,29 +24,42 @@ import createRandomTransaction from '../utils/collections/transaction'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -const translate: LocalizedTranslate = (phrase, ...parameters) => { - if (phrase === 'workspace.people.officeBased') { - return 'Office-based'; - } - if (phrase === 'workspace.people.noRegularWorkspace') { - return 'Remote or mobile'; - } - if (phrase === 'workspaceActions.updatedMemberWorkArrangement') { - const arrangement = parameters[0]; - if (typeof arrangement === 'object' && arrangement !== null && 'displayName' in arrangement && 'newArrangement' in arrangement && 'oldArrangement' in arrangement) { - return `changed ${String(arrangement.displayName)}'s work arrangement to ${String(arrangement.newArrangement)} (previously ${String(arrangement.oldArrangement)})`; - } - return String(phrase); - } - return String(phrase); -}; - describe('DistanceRate', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); 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); + } + }, + }); + }); + } + describe('deletePolicyDistanceRates', () => { it('should set customUnitOutOfPolicy violation only for transactions that have the deleted custom unit rate', async () => { const customUnitID = '5A55C2B68DDCB'; @@ -254,21 +267,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(); @@ -494,21 +492,6 @@ describe('DistanceRate', () => { describe('setPolicyDistanceRatesUnit', () => { const customUnitID = '5A55C2B68DDCB'; - function getPolicyFromOnyx(policyID: string): Promise { - return new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const, - callback: (value) => { - if (!value) { - return; - } - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); - } - afterEach(() => { resetQueue(); return Onyx.clear(); @@ -682,44 +665,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); @@ -728,7 +673,7 @@ describe('DistanceRate', () => { await waitForBatchedUpdates(); } - it('should optimistically update the member and create one changelog action with pending ADD', async () => { + it('should optimistically update only the member that changes, without adding a change log', async () => { // Given two members in the policy and only one needs an arrangement change const policy: Policy = { ...createRandomPolicy(20), @@ -741,32 +686,17 @@ describe('DistanceRate', () => { pause(); // When both members are assigned the office-based arrangement - setEmployeeWorkArrangement(policy, [member1AccountID, member2AccountID], true, personalDetails, translate); + setEmployeeWorkArrangement(policy, [member1AccountID, member2AccountID], true, personalDetails); await waitForBatchedUpdates(); - // Then only the changed member and one pending changelog entry are updated + // Then only the changed member is updated const onyxPolicy = await getPolicyFromOnyx(policy.id); expect(onyxPolicy.employeeList?.[member1Email]).toEqual({email: member1Email, hasOfficeWorkArrangement: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}); // Member2 already matches, so it is skipped without any optimistic footprint expect(onyxPolicy.employeeList?.[member2Email]).toEqual({email: member2Email, hasOfficeWorkArrangement: true}); - const reportActions = await getReportActionsFromOnyx('1'); - const actions = Object.values(reportActions); - expect(actions).toHaveLength(1); - expect(actions.at(0)).toMatchObject({ - actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - message: [ - { - text: translate('workspaceActions.updatedMemberWorkArrangement', { - displayName: 'Member One', - newArrangement: translate('workspace.people.officeBased'), - oldArrangement: translate('workspace.people.noRegularWorkspace'), - }), - }, - ], - originalMessage: {accountID: member1AccountID, email: member1Email, name: 'Member One', newValue: true, oldValue: false}, - }); + // And the admins room gets no optimistic change log, since the server can thread the real one + expect(await getReportActionsFromOnyx('1')).toEqual({}); resetQueue(); }); @@ -787,7 +717,7 @@ describe('DistanceRate', () => { pause(); // When the member's arrangement is changed before the invite finishes syncing - setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails, translate); + setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails); await waitForBatchedUpdates(); // Then the arrangement updates while the invite remains visibly pending @@ -810,7 +740,7 @@ describe('DistanceRate', () => { pause(); // When the matching member and an unknown account are passed - setEmployeeWorkArrangement(policy, [member1AccountID, 999999001], false, personalDetails, translate); + setEmployeeWorkArrangement(policy, [member1AccountID, 999999001], false, personalDetails); await waitForBatchedUpdates(); // Then no member data or changelog entry is changed @@ -838,22 +768,23 @@ describe('DistanceRate', () => { ...personalDetails, [temporaryAccountID]: {accountID: temporaryAccountID, login: member1Email, displayName: 'Member One'}, }; - setEmployeeWorkArrangement(policy, [temporaryAccountID], true, personalDetailsWithTemporaryAccountID, translate); + setEmployeeWorkArrangement(policy, [temporaryAccountID], true, personalDetailsWithTemporaryAccountID); // Then the request carries the stable login with the temporary accountID so it still applies after an offline invite syncs expect(writeSpy.mock.calls.at(0)?.[1]).toMatchObject({employeeAccountIDList: String(temporaryAccountID), employeeLoginList: member1Email}); - // And success updates clear the pending state for both the member and changelog action + // And success updates clear the member's pending state 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}); + expect(onyxData?.successData).toEqual([ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + value: {employeeList: {[member1Email]: {pendingAction: null}}}, + }, + ]); }); - it('should revert the member and drop the changelog entry on failure', async () => { + it('should revert the member on failure', async () => { // Given the member and admins room are in Onyx const policy: Policy = { ...createRandomPolicy(23), @@ -864,9 +795,9 @@ describe('DistanceRate', () => { const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); // When the member's work arrangement is updated - setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails, translate); + setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails); - // Then failure data restores the member value, retains UPDATE so dismissing the error preserves the member, and removes the changelog action + // Then failure data restores the member value and retains UPDATE so dismissing the error preserves the member const onyxData = writeSpy.mock.calls.at(0)?.[2]; expect(onyxData?.failureData?.[0]).toMatchObject({ onyxMethod: Onyx.METHOD.MERGE, @@ -906,12 +837,7 @@ describe('DistanceRate', () => { await waitForBatchedUpdates(); expect((await getPolicyFromOnyx(policy.id)).employeeList?.[member1Email]?.hasOfficeWorkArrangement).toBeUndefined(); expect(getEffectiveWorkArrangement((await getPolicyFromOnyx(policy.id)).employeeList?.[member1Email]?.hasOfficeWorkArrangement, false)).toBe(false); - - expect(onyxData?.failureData?.[1]).toMatchObject({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`, - }); - expect(getSingleUpdateValue(onyxData?.failureData?.[1]?.value)).toBeNull(); + expect(onyxData?.failureData).toHaveLength(1); }); it('should update members without changelog actions when there is no admins room', async () => { @@ -929,7 +855,7 @@ describe('DistanceRate', () => { pause(); // When the member's work arrangement is updated - setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails, translate); + setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails); await waitForBatchedUpdates(); // Then the member is updated without creating a changelog action @@ -960,7 +886,7 @@ describe('DistanceRate', () => { await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetailsWithGhost); await waitForBatchedUpdates(); // When the member missing from the policy employee list is passed - setEmployeeWorkArrangement(policy, [missingMemberAccountID], true, personalDetailsWithGhost, translate); + setEmployeeWorkArrangement(policy, [missingMemberAccountID], true, personalDetailsWithGhost); await waitForBatchedUpdates(); // Then no member data or changelog action is created @@ -983,7 +909,7 @@ describe('DistanceRate', () => { pause(); // When the member is assigned an arrangement without a policy ID - setEmployeeWorkArrangement({...policy, id: ''}, [member1AccountID], true, personalDetails, translate); + setEmployeeWorkArrangement({...policy, id: ''}, [member1AccountID], true, personalDetails); await waitForBatchedUpdates(); // Then neither the policy nor the admins room changelog is changed @@ -996,4 +922,164 @@ 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 only the member that changes, without adding a change log', 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); + await waitForBatchedUpdates(); + + // Then only the member who changed is updated + 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'}); + + // And the admins room gets no optimistic change log, since the server can thread the real one + expect(await getReportActionsFromOnyx('2')).toEqual({}); + + 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); + 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 = { + ...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); + await waitForBatchedUpdates(); + + // 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, + employeeLoginList: `${member1Email},${member2Email}`, + officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, + }); + + 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); + + // Then success clears the member's pending state + const onyxData = writeSpy.mock.calls.at(0)?.[2]; + expect(onyxData?.successData).toEqual([ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + value: {employeeList: {[member1Email]: {pendingAction: null}}}, + }, + ]); + }); + + it('should restore the previous office of each member 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); + + // 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}, + }, + }, + }); + expect(onyxData?.failureData).toHaveLength(1); + }); + }); }); diff --git a/tests/unit/OfficeLocationUtilsTest.ts b/tests/unit/OfficeLocationUtilsTest.ts new file mode 100644 index 000000000000..026974a27776 --- /dev/null +++ b/tests/unit/OfficeLocationUtilsTest.ts @@ -0,0 +1,107 @@ +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('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 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); + }); + }); + + 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 7a189d728050..f87de12ce49b 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -44,6 +44,7 @@ import { getCurrencyDefaultTaxUpdateMessage, getCustomTaxNameUpdateMessage, getDeletedOfficeLocationMessage, + getUpdatedEmployeeOfficeLocationMessage, getForeignCurrencyDefaultTaxUpdateMessage, getHumanAgentAccountIDFromReportAction, getHumanAgentFirstName, @@ -5229,6 +5230,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', () => { diff --git a/tests/unit/ReportAlternateTextUtilsTest.ts b/tests/unit/ReportAlternateTextUtilsTest.ts index 55d635880916..4db2403eeb41 100644 --- a/tests/unit/ReportAlternateTextUtilsTest.ts +++ b/tests/unit/ReportAlternateTextUtilsTest.ts @@ -27,6 +27,7 @@ import { getAddedOfficeLocationMessage, getUpdatedOfficeLocationMessage, getDeletedOfficeLocationMessage, + getUpdatedEmployeeOfficeLocationMessage, getUnassignedCompanyCardMessage, getUpdatedAutoHarvestingMessage, getUpdatedCardFeedLiabilityMessage, @@ -1639,6 +1640,11 @@ describe('ReportAlternateTextUtils', () => { getUpdatedOfficeLocationMessage, ], [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION, {officeID: 'OFFICE1', name: 'Headquarters'}, getDeletedOfficeLocationMessage], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION, + {accountID: 1, email: 'member@test.com', name: 'Member One', officeID: 'OFFICE2', officeName: 'New Jersey Office', oldOfficeID: '', oldOfficeName: ''}, + getUpdatedEmployeeOfficeLocationMessage, + ], ] as const)('%s action', async (actionName, originalMessage, getMessage) => { // Given an office location change log, whose stored text is Auth's English message const report: Report = createRandomReport(0, undefined); diff --git a/tests/unit/ReportNameUtilsTest.ts b/tests/unit/ReportNameUtilsTest.ts index b3a92392e7eb..80abad3ddaec 100644 --- a/tests/unit/ReportNameUtilsTest.ts +++ b/tests/unit/ReportNameUtilsTest.ts @@ -1,6 +1,14 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import {translate} from '@libs/Localize'; +import { + getAddedOfficeLocationMessage, + getDeletedOfficeLocationMessage, + getPolicyWorkArrangementMessage, + getUpdatedEmployeeOfficeLocationMessage, + getUpdatedMemberWorkArrangementMessage, + getUpdatedOfficeLocationMessage, +} from '@libs/ReportActionsUtils'; import { buildReportNameFromParticipantNames, // eslint-disable-next-line no-restricted-imports -- testing computeReportName directly @@ -1012,6 +1020,59 @@ describe('ReportNameUtils', () => { expect(disabledName).toBe('disabled the expense categorization requirement'); }); + test.each([ + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_POLICY_WORK_ARRANGEMENT, {newValue: true, oldValue: false}, getPolicyWorkArrangementMessage], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT, + {accountID: 1, email: 'member@test.com', name: 'Member One', newValue: true, oldValue: false}, + getUpdatedMemberWorkArrangementMessage, + ], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}}, + getAddedOfficeLocationMessage, + ], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'HQ', updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.NAME, newValue: 'HQ', oldValue: 'Headquarters'}, + getUpdatedOfficeLocationMessage, + ], + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION, {officeID: 'OFFICE1', name: 'Headquarters'}, getDeletedOfficeLocationMessage], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION, + {accountID: 1, email: 'member@test.com', name: 'Member One', officeID: 'OFFICE2', officeName: 'New Jersey Office', oldOfficeID: '', oldOfficeName: ''}, + getUpdatedEmployeeOfficeLocationMessage, + ], + ] as const)('%s parent action', (actionName, originalMessage, getMessage) => { + // Given a thread on a work arrangement or office location change log, whose stored text is Auth's English message + const thread: Report = createWorkspaceThread(171); + const parentId = String(thread.parentReportID); + const actionId = String(thread.parentReportActionID); + const parentAction: ReportAction = { + ...createRandomReportAction(171), + actionName, + reportActionID: actionId, + message: [{type: 'COMMENT', text: 'stored message', html: 'stored message'}], + originalMessage, + }; + + // When the thread is named after that change log + const name = computeReportName( + thread, + emptyCollections.reports, + emptyCollections.policies, + undefined, + undefined, + participantsPersonalDetails, + {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentId}`]: {[actionId]: parentAction}}, + currentUserAccountID, + ); + + // Then the name reads the same as the in-chat change log rather than the stored text + expect(name).toBe(getMessage(translateLocal, parentAction)); + expect(name).not.toBe('stored message'); + }); + test('UPDATE_REQUIRES_TAG parent action', () => { const thread: Report = createWorkspaceThread(152); const parentId = String(thread.parentReportID);