diff --git a/android/app/build.gradle b/android/app/build.gradle
index e3f4e9bb9f0d..7b8271cfcbf2 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -98,8 +98,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001047001
- versionName "1.4.70-1"
+ versionCode 1001047002
+ versionName "1.4.70-2"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/assets/animations/Plane.lottie b/assets/animations/Plane.lottie
new file mode 100644
index 000000000000..5244cb7bea10
Binary files /dev/null and b/assets/animations/Plane.lottie differ
diff --git a/assets/images/simple-illustrations/simple-illustration__alert.svg b/assets/images/simple-illustrations/simple-illustration__alert.svg
new file mode 100644
index 000000000000..2e7bca02f5e3
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__alert.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/images/simple-illustrations/simple-illustration__piggybank.svg b/assets/images/simple-illustrations/simple-illustration__piggybank.svg
index a9cf2b02c5dc..be87ff34752a 100644
--- a/assets/images/simple-illustrations/simple-illustration__piggybank.svg
+++ b/assets/images/simple-illustrations/simple-illustration__piggybank.svg
@@ -1,50 +1,50 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/images/suitcase.svg b/assets/images/suitcase.svg
new file mode 100644
index 000000000000..97036db6b5ac
--- /dev/null
+++ b/assets/images/suitcase.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/desktop/ELECTRON_EVENTS.ts b/desktop/ELECTRON_EVENTS.ts
index de0bd655e12c..607ad7b21580 100644
--- a/desktop/ELECTRON_EVENTS.ts
+++ b/desktop/ELECTRON_EVENTS.ts
@@ -9,6 +9,7 @@ const ELECTRON_EVENTS = {
KEYBOARD_SHORTCUTS_PAGE: 'keyboard-shortcuts-page',
START_UPDATE: 'start-update',
UPDATE_DOWNLOADED: 'update-downloaded',
+ SILENT_UPDATE: 'silent-update',
} as const;
export default ELECTRON_EVENTS;
diff --git a/desktop/contextBridge.ts b/desktop/contextBridge.ts
index 689c69de0cc8..487e528a7485 100644
--- a/desktop/contextBridge.ts
+++ b/desktop/contextBridge.ts
@@ -16,6 +16,7 @@ const WHITELIST_CHANNELS_RENDERER_TO_MAIN = [
ELECTRON_EVENTS.REQUEST_VISIBILITY,
ELECTRON_EVENTS.START_UPDATE,
ELECTRON_EVENTS.LOCALE_UPDATED,
+ ELECTRON_EVENTS.SILENT_UPDATE,
] as const;
const WHITELIST_CHANNELS_MAIN_TO_RENDERER = [ELECTRON_EVENTS.KEYBOARD_SHORTCUTS_PAGE, ELECTRON_EVENTS.UPDATE_DOWNLOADED, ELECTRON_EVENTS.FOCUS, ELECTRON_EVENTS.BLUR] as const;
diff --git a/desktop/main.ts b/desktop/main.ts
index 6e14d661b345..b40557464ec1 100644
--- a/desktop/main.ts
+++ b/desktop/main.ts
@@ -111,6 +111,7 @@ process.argv.forEach((arg) => {
// happens correctly.
let hasUpdate = false;
let downloadedVersion: string;
+let isSilentUpdating = false;
// Note that we have to subscribe to this separately and cannot use Localize.translateLocal,
// because the only way code can be shared between the main and renderer processes at runtime is via the context bridge
@@ -127,16 +128,20 @@ const quitAndInstallWithUpdate = () => {
autoUpdater.quitAndInstall();
};
-/** Menu Item callback to triggers an update check */
-const manuallyCheckForUpdates = (menuItem: MenuItem, browserWindow?: BrowserWindow) => {
- // Disable item until the check (and download) is complete
- // eslint: menu item flags like enabled or visible can be dynamically toggled by mutating the object
- // eslint-disable-next-line no-param-reassign
- menuItem.enabled = false;
+/** Menu Item callback to trigger an update check */
+const manuallyCheckForUpdates = (menuItem?: MenuItem, browserWindow?: BrowserWindow) => {
+ if (menuItem) {
+ // Disable item until the check (and download) is complete
+ // eslint-disable-next-line no-param-reassign -- menu item flags like enabled or visible can be dynamically toggled by mutating the object
+ menuItem.enabled = false;
+ }
autoUpdater
.checkForUpdates()
- .catch((error) => ({error}))
+ .catch((error) => {
+ isSilentUpdating = false;
+ return {error};
+ })
.then((result) => {
const downloadPromise = result && 'downloadPromise' in result ? result.downloadPromise : undefined;
@@ -148,7 +153,7 @@ const manuallyCheckForUpdates = (menuItem: MenuItem, browserWindow?: BrowserWind
dialog.showMessageBox(browserWindow, {
type: 'info',
message: Localize.translate(preferredLocale, 'checkForUpdatesModal.available.title'),
- detail: Localize.translate(preferredLocale, 'checkForUpdatesModal.available.message'),
+ detail: Localize.translate(preferredLocale, 'checkForUpdatesModal.available.message', {isSilentUpdating}),
buttons: [Localize.translate(preferredLocale, 'checkForUpdatesModal.available.soundsGood')],
});
} else if (result && 'error' in result && result.error) {
@@ -172,6 +177,10 @@ const manuallyCheckForUpdates = (menuItem: MenuItem, browserWindow?: BrowserWind
return downloadPromise;
})
.finally(() => {
+ isSilentUpdating = false;
+ if (!menuItem) {
+ return;
+ }
// eslint-disable-next-line no-param-reassign
menuItem.enabled = true;
});
@@ -201,7 +210,7 @@ const electronUpdater = (browserWindow: BrowserWindow): PlatformSpecificUpdater
if (checkForUpdatesMenuItem) {
checkForUpdatesMenuItem.visible = false;
}
- if (browserWindow.isVisible()) {
+ if (browserWindow.isVisible() && !isSilentUpdating) {
browserWindow.webContents.send(ELECTRON_EVENTS.UPDATE_DOWNLOADED, info.version);
} else {
quitAndInstallWithUpdate();
@@ -604,6 +613,15 @@ const mainWindow = (): Promise => {
}
});
+ // Automatically check for and install the latest version in the background
+ ipcMain.on(ELECTRON_EVENTS.SILENT_UPDATE, () => {
+ if (isSilentUpdating) {
+ return;
+ }
+ isSilentUpdating = true;
+ manuallyCheckForUpdates(undefined, browserWindow);
+ });
+
return browserWindow;
})
diff --git a/docs/articles/expensify-classic/expensify-billing/Billing-Overview.md b/docs/articles/expensify-classic/expensify-billing/Billing-Overview.md
index ed44caad546b..a998e279c3f6 100644
--- a/docs/articles/expensify-classic/expensify-billing/Billing-Overview.md
+++ b/docs/articles/expensify-classic/expensify-billing/Billing-Overview.md
@@ -23,7 +23,7 @@ Bundling the Expensify Card with an annual subscription ensures you pay the lowe
If at least 50% of your approved USD spend in a given month is on your company’s Expensify Cards, you will receive an additional 50% discount on the price per member. This additional 50% discount, when coupled with an annual subscription, brings the price per member to $5 on a Collect plan and $9 on a Control plan.
-Additionally, every month, you receive 1% cash back on all Expensify Card purchases, and 2% if the spend across your Expensify Cards is $250k or more (_applies to USD purchases only_). Any cash back from the Expensify Card is first applied to your Expensify bill, further reducing your price per member. Any leftover cash back is deposited directly into your connected bank account.
+Additionally, every month, you receive 1% cash back on all Expensify Card purchases, and 2% if the spend across your Expensify Cards is $250k or more (_applies to US purchases only_). Any cash back from the Expensify Card is first applied to your Expensify bill, further reducing your price per member. Any leftover cash back is deposited directly into your connected bank account.
## Savings calculator
To see how much money you can save (and even earn!) by using the Expensify Card, check out our [savings calculator](https://use.expensify.com/price-savings-calculator). Just enter a few details and see how much you’ll save!
@@ -34,11 +34,9 @@ If less than 50% of your total USD spend is on the Expensify Card, the bill is d
**Example:**
- Annual subscription discount: 50%
-- % of Expensify Card spend (USD) across all workspaces: 20%
+- % of Expensify Card spend (US purchases only) across all workspaces: 20%
- Expensify Card discount: 20%
In that case, you'd save 70% on the price per member for that month's bill.
-Note: USD spend refers to approved USD transactions on the Expensify Card in any given month.
-
{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/workspaces/Remove-Members.md b/docs/articles/expensify-classic/workspaces/Remove-Members.md
new file mode 100644
index 000000000000..a79d75edc77d
--- /dev/null
+++ b/docs/articles/expensify-classic/workspaces/Remove-Members.md
@@ -0,0 +1,33 @@
+---
+title: Remove a Workspace Member
+description: How to remove a member from a Workspace in Expensify
+---
+
+Removing a member from a workspace prevents them from submitting reports to or accessing the workspace. Please note that it does not delete their account or deactivate their Expensify Card.
+
+## Remove a Workspace Member
+Important: Make sure the employee has submitted all reports, and the reports have been approved and reimbursed, and are in the final approval state.
+1. Go to Settings > Workspaces > Group > [Workspace Name] > Members > Workspace Members
+2. Select the member you'd like to remove and click the **Remove** button at the top of the Members table
+3. If this member was an approver, update the approval workflow so that reports are no longer routed to them
+
+{:width="100%"}
+
+{% include faq-begin.md %}
+
+## Will reports from this member on this workspace still be available?
+Yes, as long as the reports have been submitted. You can navigate to the Reports page and enter the member's email in the search field to find them. However, Draft reports will be removed from the workspace, so these will no longer be visible to the Workspace Admin.
+
+## Can members still access their reports on a workspace after they have been removed?
+Yes. Any report that has been approved will now show the workspace as “(not shared)” in their account. If it is a Draft Report they will still be able to edit it and add it to a new workspace. If the report is Approved or Reimbursed they will not be able to edit it further.
+
+## Who can remove members from a workspace?
+Only Workspace Admins. It is not possible for a member to add or remove themselves from a workspace. It is not possible for a Domain Admin who is not also a Workspace Admin to remove a member from a workspace.
+
+## How do I remove a member from a workspace if I am seeing an error message?
+If a member is a **preferred exporter, billing owner, report approver** or has **processing reports**, to remove them from the workspace you will first need to:
+
+* **Preferred Exporter**: Go to Settings > Workspaces > Group > [Workspace Name] > Connections > Configure and select a different Workspace Admin in the dropdown for **Preferred Exporter**.
+* **Billing Owner**: Take over billing on the Settings > Workspaces > Group > [Workspace Name] > Overview page.
+* **Processing reports**: Approve or reject the member’s reports on your Reports page.
+* **Approval Workflow**: Remove them as a workflow approver on your Settings > Workspaces > Group > [Workspace Name] > Members > Approval Mode > page by changing the "**Submit reports to**" field.
diff --git a/docs/redirects.csv b/docs/redirects.csv
index c887b649b32d..f775d2f97094 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -169,3 +169,4 @@ https://help.expensify.com/articles/new-expensify/expenses/Referral-Program,http
https://help.expensify.com/articles/expensify-classic/reports/Report-Audit-Log-and-Comments,https://help.expensify.com/articles/expensify-classic/reports/Print-or-download-a-report
https://help.expensify.com/articles/expensify-classic/reports/The-Reports-Page,https://help.expensify.com/articles/expensify-classic/reports/Report-statuses
https://help.expensify.com/articles/new-expensify/getting-started/Free-plan-upgrade-to-collect-plan,https://help.expensify.com/articles/new-expensify/getting-started/Upgrade-to-a-Collect-Plan
+https://help.expensify.com/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account,https://help.expensify.com/new-expensify/hubs/expenses/Connect-a-Bank-Account
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index a519e0ebf0dd..647c12d51353 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.70.1
+ 1.4.70.2
FullStory
OrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index b359e93157c7..c692d84e11a2 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.70.1
+ 1.4.70.2
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index ab7622c2820d..8bb62f9d5f56 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString
1.4.70
CFBundleVersion
- 1.4.70.1
+ 1.4.70.2
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 548a93ed6e51..c4c89ebb99cf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.70-1",
+ "version": "1.4.70-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.70-1",
+ "version": "1.4.70-2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -100,7 +100,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "2.0.35",
+ "react-native-onyx": "2.0.32",
"react-native-pager-view": "6.2.3",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
@@ -31431,9 +31431,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "2.0.35",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.35.tgz",
- "integrity": "sha512-eQwXQoYpv6Wv1sDrR2Otl4mW34U8OZPtlpju3OyGv1KpQSQ+2q8Ivju7AHc3DG+j2QHypUKngQghKdJ9Sm3jBQ==",
+ "version": "2.0.32",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.32.tgz",
+ "integrity": "sha512-tB9wqMJGTLOYfrfplRP+9aq5JdD8w/hV/OZsMAVH+ewbE1zLY8OymUsAsIFdF1v+cB8HhehP569JVLZmhm6bsg==",
"dependencies": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
diff --git a/package.json b/package.json
index c1b599baa952..9530389b1fb8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.70-1",
+ "version": "1.4.70-2",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -152,7 +152,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "2.0.35",
+ "react-native-onyx": "2.0.32",
"react-native-pager-view": "6.2.3",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
diff --git a/patches/@rnmapbox+maps+10.1.11.patch b/patches/@rnmapbox+maps+10.1.12.patch
similarity index 99%
rename from patches/@rnmapbox+maps+10.1.11.patch
rename to patches/@rnmapbox+maps+10.1.12.patch
index 5c5b8f0b69bb..c8135280056c 100644
--- a/patches/@rnmapbox+maps+10.1.11.patch
+++ b/patches/@rnmapbox+maps+10.1.12.patch
@@ -1,5 +1,5 @@
diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt
-index dbd6d0b..1d043f2 100644
+index 0d876a7..cceed39 100644
--- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt
+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt
@@ -188,7 +188,7 @@ class RNMBXCamera(private val mContext: Context, private val mManager: RNMBXCame
diff --git a/src/CONST.ts b/src/CONST.ts
index 310bb3959300..566d5179f86a 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -584,6 +584,7 @@ const CONST = {
ONFIDO_PRIVACY_POLICY_URL: 'https://onfido.com/privacy/',
ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/',
LIST_OF_RESTRICTED_BUSINESSES: 'https://community.expensify.com/discussion/6191/list-of-restricted-businesses',
+ TRAVEL_TERMS_URL: `${USE_EXPENSIFY_URL}/travelterms`,
// Use Environment.getEnvironmentURL to get the complete URL with port number
DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:',
@@ -668,6 +669,7 @@ const CONST = {
MANAGER_DETACH_RECEIPT: 'MANAGERDETACHRECEIPT', // OldDot Action
MARKED_REIMBURSED: 'MARKEDREIMBURSED', // OldDot Action
MARK_REIMBURSED_FROM_INTEGRATION: 'MARKREIMBURSEDFROMINTEGRATION', // OldDot Action
+ MERGED_WITH_CASH_TRANSACTION: 'MERGEDWITHCASHTRANSACTION',
MODIFIED_EXPENSE: 'MODIFIEDEXPENSE',
MOVED: 'MOVED',
OUTDATED_BANK_ACCOUNT: 'OUTDATEDBANKACCOUNT', // OldDot Action
@@ -842,7 +844,7 @@ const CONST = {
},
RESERVED_ROOM_NAMES: ['#admins', '#announce'],
MAX_PREVIEW_AVATARS: 4,
- MAX_ROOM_NAME_LENGTH: 79,
+ MAX_ROOM_NAME_LENGTH: 99,
LAST_MESSAGE_TEXT_MAX_LENGTH: 200,
OWNER_EMAIL_FAKE: '__FAKE__',
OWNER_ACCOUNT_ID_FAKE: 0,
@@ -917,6 +919,15 @@ const CONST = {
SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300,
RESIZE_DEBOUNCE_TIME: 100,
},
+ SEARCH_TABLE_COLUMNS: {
+ DATE: 'date',
+ MERCHANT: 'merchant',
+ FROM: 'from',
+ TO: 'to',
+ TOTAL: 'total',
+ TYPE: 'type',
+ ACTION: 'action',
+ },
PRIORITY_MODE: {
GSD: 'gsd',
DEFAULT: 'default',
@@ -1846,7 +1857,7 @@ const CONST = {
CARD_NUMBER: /^[0-9]{15,16}$/,
CARD_SECURITY_CODE: /^[0-9]{3,4}$/,
CARD_EXPIRATION_DATE: /^(0[1-9]|1[0-2])([^0-9])?([0-9]{4}|([0-9]{2}))$/,
- ROOM_NAME: /^#[\p{Ll}0-9-]{1,80}$/u,
+ ROOM_NAME: /^#[\p{Ll}0-9-]{1,100}$/u,
// eslint-disable-next-line max-len, no-misleading-character-class
EMOJI: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
@@ -2026,7 +2037,6 @@ const CONST = {
INFO: 'info',
},
REPORT_DETAILS_MENU_ITEM: {
- SHARE_CODE: 'shareCode',
MEMBERS: 'member',
INVITE: 'invite',
SETTINGS: 'settings',
@@ -3419,11 +3429,6 @@ const CONST = {
},
TAB_SEARCH: {
ALL: 'all',
- // @TODO: Uncomment when the queries below are implemented
- // SHARED: 'shared',
- // DRAFTS: 'drafts',
- // WAITING_ON_YOU: 'waitingOnYou',
- // FINISHED: 'finished',
},
STATUS_TEXT_MAX_LENGTH: 100,
@@ -4698,6 +4703,14 @@ const CONST = {
MAX_TAX_RATE_INTEGER_PLACES: 4,
MAX_TAX_RATE_DECIMAL_PLACES: 4,
+ DOWNLOADS_PATH: '/Downloads',
+ NEW_EXPENSIFY_PATH: '/New Expensify',
+
+ ENVIRONMENT_SUFFIX: {
+ DEV: ' Dev',
+ ADHOC: ' AdHoc',
+ },
+
SEARCH_TRANSACTION_TYPE: {
CASH: 'cash',
CARD: 'card',
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 1a27d691e2ef..88818c3eb7c6 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -282,6 +282,9 @@ const ONYXKEYS = {
/** Onboarding Purpose selected by the user during Onboarding flow */
ONBOARDING_PURPOSE_SELECTED: 'onboardingPurposeSelected',
+ /** Onboarding Purpose selected by the user during Onboarding flow */
+ ONBOARDING_ADMINS_CHAT_REPORT_ID: 'onboardingAdminsChatReportID',
+
// Max width supported for HTML element
MAX_CANVAS_WIDTH: 'maxCanvasWidth',
@@ -655,6 +658,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.MAX_CANVAS_HEIGHT]: number;
[ONYXKEYS.MAX_CANVAS_WIDTH]: number;
[ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string;
+ [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string;
[ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean;
[ONYXKEYS.LAST_VISITED_PATH]: string | undefined;
[ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index c274e8abdf08..3d73164802d7 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -758,8 +758,10 @@ const ROUTES = {
route: 'referral/:contentType',
getRoute: (contentType: string, backTo?: string) => getUrlWithBackToParam(`referral/${contentType}`, backTo),
},
- TRACK_TRAINING_MODAL: 'track-training',
PROCESS_MONEY_REQUEST_HOLD: 'hold-expense-educational',
+ TRAVEL_MY_TRIPS: 'travel',
+ TRAVEL_TCS: 'travel/terms',
+ TRACK_TRAINING_MODAL: 'track-training',
ONBOARDING_ROOT: 'onboarding',
ONBOARDING_PERSONAL_DETAILS: 'onboarding/personal-details',
ONBOARDING_WORK: 'onboarding/work',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index e9f180a54879..d9f92382bc95 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -23,6 +23,10 @@ const SCREENS = {
CONNECTION_COMPLETE: 'ConnectionComplete',
UNLINK_LOGIN: 'UnlinkLogin',
SETTINGS_CENTRAL_PANE: 'SettingsCentralPane',
+ TRAVEL: {
+ MY_TRIPS: 'Travel_MyTrips',
+ TCS: 'Travel_TCS',
+ },
WORKSPACES_CENTRAL_PANE: 'WorkspacesCentralPane',
SEARCH: {
CENTRAL_PANE: 'Search_Central_Pane',
@@ -133,6 +137,7 @@ const SCREENS = {
ROOM_INVITE: 'RoomInvite',
REFERRAL: 'Referral',
PROCESS_MONEY_REQUEST_HOLD: 'ProcessMoneyRequestHold',
+ TRAVEL: 'Travel',
SEARCH_REPORT: 'SearchReport',
},
ONBOARDING_MODAL: {
diff --git a/src/components/AvatarCropModal/ImageCropView.tsx b/src/components/AvatarCropModal/ImageCropView.tsx
index c79a209376b4..b7f2a64090d6 100644
--- a/src/components/AvatarCropModal/ImageCropView.tsx
+++ b/src/components/AvatarCropModal/ImageCropView.tsx
@@ -86,6 +86,7 @@ function ImageCropView({imageUri = '', containerSize = 0, panGesture = Gesture.P
// fill={theme.iconReversed}
width={containerSize}
height={containerSize}
+ key={containerSize}
/>
diff --git a/src/components/ChatDetailsQuickActionsBar.tsx b/src/components/ChatDetailsQuickActionsBar.tsx
index f15fc31aec45..d289587ce953 100644
--- a/src/components/ChatDetailsQuickActionsBar.tsx
+++ b/src/components/ChatDetailsQuickActionsBar.tsx
@@ -1,11 +1,12 @@
-import React, {useState} from 'react';
+import React from 'react';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
import * as Report from '@userActions/Report';
+import ROUTES from '@src/ROUTES';
import type {Report as OnyxReportType} from '@src/types/onyx';
import Button from './Button';
-import ConfirmModal from './ConfirmModal';
import * as Expensicons from './Icon/Expensicons';
type ChatDetailsQuickActionsBarProps = {
@@ -14,45 +15,26 @@ type ChatDetailsQuickActionsBarProps = {
function ChatDetailsQuickActionsBar({report}: ChatDetailsQuickActionsBarProps) {
const styles = useThemeStyles();
- const [isLastMemberLeavingGroupModalVisible, setIsLastMemberLeavingGroupModalVisible] = useState(false);
const {translate} = useLocalize();
const isPinned = !!report.isPinned;
return (
- {
- setIsLastMemberLeavingGroupModalVisible(false);
- Report.leaveGroupChat(report.reportID);
- }}
- onCancel={() => setIsLastMemberLeavingGroupModalVisible(false)}
- prompt={translate('groupChat.lastMemberWarning')}
- confirmText={translate('common.leave')}
- cancelText={translate('common.cancel')}
- />
{
- if (Object.keys(report?.participants ?? {}).length === 1) {
- setIsLastMemberLeavingGroupModalVisible(true);
- return;
- }
-
- Report.leaveGroupChat(report.reportID);
- }}
- icon={Expensicons.Exit}
+ onPress={() => Report.togglePinnedState(report.reportID, isPinned)}
+ icon={Expensicons.Pin}
style={styles.flex1}
- text={translate('common.leave')}
+ text={isPinned ? translate('common.unPin') : translate('common.pin')}
/>
Report.togglePinnedState(report.reportID, isPinned)}
- icon={Expensicons.Pin}
+ onPress={() => {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.getRoute(report?.reportID ?? ''));
+ }}
+ icon={Expensicons.QrCode}
style={styles.flex1}
- text={isPinned ? translate('common.unPin') : translate('common.pin')}
+ text={translate('common.share')}
/>
diff --git a/src/components/ClientSideLoggingToolMenu/BaseClientSideLoggingToolMenu.tsx b/src/components/ClientSideLoggingToolMenu/BaseClientSideLoggingToolMenu.tsx
index fcad770908a6..7c4c669e2154 100644
--- a/src/components/ClientSideLoggingToolMenu/BaseClientSideLoggingToolMenu.tsx
+++ b/src/components/ClientSideLoggingToolMenu/BaseClientSideLoggingToolMenu.tsx
@@ -30,9 +30,11 @@ type BaseClientSideLoggingToolProps = {
onDisableLogging: (logs: Log[]) => void;
/** Action to run when enabling logging */
onEnableLogging?: () => void;
+ /** Path used to display location of saved file */
+ displayPath?: string;
} & BaseClientSideLoggingToolMenuOnyxProps;
-function BaseClientSideLoggingToolMenu({shouldStoreLogs, capturedLogs, file, onShareLogs, onDisableLogging, onEnableLogging}: BaseClientSideLoggingToolProps) {
+function BaseClientSideLoggingToolMenu({shouldStoreLogs, capturedLogs, file, onShareLogs, onDisableLogging, onEnableLogging, displayPath}: BaseClientSideLoggingToolProps) {
const {translate} = useLocalize();
const onToggle = () => {
@@ -70,7 +72,7 @@ function BaseClientSideLoggingToolMenu({shouldStoreLogs, capturedLogs, file, onS
{!!file && (
<>
- {`path: ${file.path}`}
+ {`path: ${displayPath}`}
setFile(undefined)}
onDisableLogging={createAndSaveFile}
onShareLogs={shareLogs}
+ displayPath={`${CONST.DOWNLOADS_PATH}/${file?.newFileName ?? ''}`}
/>
);
}
diff --git a/src/components/ClientSideLoggingToolMenu/index.ios.tsx b/src/components/ClientSideLoggingToolMenu/index.ios.tsx
index cc596e54a973..78ffccf612a2 100644
--- a/src/components/ClientSideLoggingToolMenu/index.ios.tsx
+++ b/src/components/ClientSideLoggingToolMenu/index.ios.tsx
@@ -1,11 +1,15 @@
import React, {useState} from 'react';
import Share from 'react-native-share';
+import useEnvironment from '@hooks/useEnvironment';
import type {Log} from '@libs/Console';
+import getDownloadFolderPathSuffixForIOS from '@libs/getDownloadFolderPathSuffixForIOS';
import localFileCreate from '@libs/localFileCreate';
+import CONST from '@src/CONST';
import BaseClientSideLoggingToolMenu from './BaseClientSideLoggingToolMenu';
function ClientSideLoggingToolMenu() {
const [file, setFile] = useState<{path: string; newFileName: string; size: number}>();
+ const {environment} = useEnvironment();
const createFile = (logs: Log[]) => {
localFileCreate('logs', JSON.stringify(logs, null, 2)).then((localFile) => {
@@ -28,6 +32,7 @@ function ClientSideLoggingToolMenu() {
onEnableLogging={() => setFile(undefined)}
onDisableLogging={createFile}
onShareLogs={shareLogs}
+ displayPath={`${CONST.NEW_EXPENSIFY_PATH}${getDownloadFolderPathSuffixForIOS(environment)}/${file?.newFileName ?? ''}`}
/>
);
}
diff --git a/src/components/FeatureList.tsx b/src/components/FeatureList.tsx
index 5f713d5f3aef..5e4ab89cf150 100644
--- a/src/components/FeatureList.tsx
+++ b/src/components/FeatureList.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import {View} from 'react-native';
-import type {StyleProp, ViewStyle} from 'react-native';
+import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
@@ -43,6 +43,12 @@ type FeatureListProps = {
/** The background color to apply in the upper half of the screen. */
illustrationBackgroundColor?: string;
+
+ /** The style used for the title */
+ titleStyles?: StyleProp;
+
+ /** Padding for content on large screens */
+ contentPaddingOnLargeScreens?: {padding: number};
};
function FeatureList({
@@ -55,6 +61,8 @@ function FeatureList({
illustration,
illustrationStyle,
illustrationBackgroundColor,
+ titleStyles,
+ contentPaddingOnLargeScreens,
}: FeatureListProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -68,6 +76,8 @@ function FeatureList({
illustration={illustration}
illustrationBackgroundColor={illustrationBackgroundColor}
illustrationStyle={illustrationStyle}
+ titleStyles={titleStyles}
+ contentPaddingOnLargeScreens={contentPaddingOnLargeScreens}
>
@@ -79,8 +89,8 @@ function FeatureList({
;
export default DotLottieAnimations;
diff --git a/src/components/ProfilingToolMenu/index.native.tsx b/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx
similarity index 88%
rename from src/components/ProfilingToolMenu/index.native.tsx
rename to src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx
index a202a39032ba..ff34db881a35 100644
--- a/src/components/ProfilingToolMenu/index.native.tsx
+++ b/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx
@@ -19,11 +19,16 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import pkg from '../../../package.json';
-type ProfilingToolMenuOnyxProps = {
+type BaseProfilingToolMenuOnyxProps = {
isProfilingInProgress: OnyxEntry;
};
-type ProfilingToolMenuProps = ProfilingToolMenuOnyxProps;
+type BaseProfilingToolMenuProps = {
+ /** Path used to save the file */
+ pathToBeUsed: string;
+ /** Path used to display location of saved file */
+ displayPath: string;
+} & BaseProfilingToolMenuOnyxProps;
function formatBytes(bytes: number, decimals = 2) {
if (!+bytes) {
@@ -39,7 +44,9 @@ function formatBytes(bytes: number, decimals = 2) {
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}
-function ProfilingToolMenu({isProfilingInProgress = false}: ProfilingToolMenuProps) {
+const newFileName = `Profile_trace_for_${pkg.version}.cpuprofile`;
+
+function BaseProfilingToolMenu({isProfilingInProgress = false, pathToBeUsed, displayPath}: BaseProfilingToolMenuProps) {
const styles = useThemeStyles();
const [pathIOS, setPathIOS] = useState('');
const [sharePath, setSharePath] = useState('');
@@ -90,8 +97,7 @@ function ProfilingToolMenu({isProfilingInProgress = false}: ProfilingToolMenuPro
// eslint-disable-next-line @lwc/lwc/no-async-await
const rename = async () => {
- const newFileName = `Profile_trace_for_${pkg.version}.cpuprofile`;
- const newFilePath = `${RNFS.DocumentDirectoryPath}/${newFileName}`;
+ const newFilePath = `${pathToBeUsed}/${newFileName}`;
try {
const fileExists = await RNFS.exists(newFilePath);
@@ -117,7 +123,7 @@ function ProfilingToolMenu({isProfilingInProgress = false}: ProfilingToolMenuPro
};
rename();
- }, [pathIOS]);
+ }, [pathIOS, pathToBeUsed]);
const onDownloadProfiling = useCallback(() => {
// eslint-disable-next-line @lwc/lwc/no-async-await
@@ -153,7 +159,7 @@ function ProfilingToolMenu({isProfilingInProgress = false}: ProfilingToolMenuPro
{!!pathIOS && (
<>
- {`path: ${pathIOS}`}
+ {`path: ${displayPath}/${newFileName}`}
({
+export default withOnyx({
isProfilingInProgress: {
key: ONYXKEYS.APP_PROFILING_IN_PROGRESS,
},
-})(ProfilingToolMenu);
+})(BaseProfilingToolMenu);
diff --git a/src/components/ProfilingToolMenu/index.android.tsx b/src/components/ProfilingToolMenu/index.android.tsx
new file mode 100644
index 000000000000..7ca3cc24c9a1
--- /dev/null
+++ b/src/components/ProfilingToolMenu/index.android.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import RNFS from 'react-native-fs';
+import CONST from '@src/CONST';
+import BaseProfilingToolMenu from './BaseProfilingToolMenu';
+
+function ProfilingToolMenu() {
+ return (
+
+ );
+}
+
+ProfilingToolMenu.displayName = 'ProfilingToolMenu';
+
+export default ProfilingToolMenu;
diff --git a/src/components/ProfilingToolMenu/index.ios.tsx b/src/components/ProfilingToolMenu/index.ios.tsx
new file mode 100644
index 000000000000..45400b6a6959
--- /dev/null
+++ b/src/components/ProfilingToolMenu/index.ios.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import RNFS from 'react-native-fs';
+import useEnvironment from '@hooks/useEnvironment';
+import getDownloadFolderPathSuffixForIOS from '@libs/getDownloadFolderPathSuffixForIOS';
+import CONST from '@src/CONST';
+import BaseProfilingToolMenu from './BaseProfilingToolMenu';
+
+function ProfilingToolMenu() {
+ const {environment} = useEnvironment();
+
+ return (
+
+ );
+}
+
+ProfilingToolMenu.displayName = 'ProfilingToolMenu';
+
+export default ProfilingToolMenu;
diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
index ea19b1494a37..7d71560d464c 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
+++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
@@ -1,4 +1,3 @@
-import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import {truncate} from 'lodash';
import lodashSortBy from 'lodash/sortBy';
import React from 'react';
@@ -11,7 +10,6 @@ import MoneyRequestSkeletonView from '@components/MoneyRequestSkeletonView';
import MultipleAvatars from '@components/MultipleAvatars';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
-import RenderHTML from '@components/RenderHTML';
import ReportActionItemImages from '@components/ReportActionItem/ReportActionItemImages';
import {showContextMenuForReport} from '@components/ShowContextMenuContext';
import Text from '@components/Text';
@@ -63,7 +61,6 @@ function MoneyRequestPreviewContent({
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const {isSmallScreenWidth, windowWidth} = useWindowDimensions();
- const parser = new ExpensiMark();
const sessionAccountID = session?.accountID;
const managerID = iouReport?.managerID ?? -1;
@@ -301,12 +298,9 @@ function MoneyRequestPreviewContent({
{!isCurrentUserManager && shouldShowPendingConversionMessage && (
{translate('iou.pendingConversionMessage')}
)}
- {shouldShowDescription && (
-
- ${parser.replace(merchantOrDescription)}`} />
-
+ {(shouldShowMerchant || shouldShowDescription) && (
+ {merchantOrDescription}
)}
- {shouldShowMerchant && {merchantOrDescription} }
{isBillSplit && participantAccountIDs.length > 0 && !!requestAmount && requestAmount > 0 && (
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index 57563d1ac181..54f9f1aa081f 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -1,4 +1,3 @@
-import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import React, {useMemo} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
@@ -9,7 +8,6 @@ import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
-import RenderHTML from '@components/RenderHTML';
import SettlementButton from '@components/SettlementButton';
import {showContextMenuForReport} from '@components/ShowContextMenuContext';
import Text from '@components/Text';
@@ -227,16 +225,14 @@ function ReportPreview({
numberOfRequests === 1 && (!!formattedMerchant || !!formattedDescription) && !(hasOnlyTransactionsWithPendingRoutes && !totalDisplaySpend);
const shouldShowSubtitle = !isScanning && (shouldShowSingleRequestMerchantOrDescription || numberOfRequests > 1);
- const {isSupportTextHtml, supportText} = useMemo(() => {
+ const {supportText} = useMemo(() => {
if (formattedMerchant) {
- return {isSupportTextHtml: false, supportText: formattedMerchant};
+ return {supportText: formattedMerchant};
}
if (formattedDescription ?? moneyRequestComment) {
- const parsedSubtitle = new ExpensiMark().replace(formattedDescription ?? moneyRequestComment);
- return {isSupportTextHtml: !!parsedSubtitle, supportText: parsedSubtitle ? `${parsedSubtitle} ` : ''};
+ return {supportText: formattedDescription ?? moneyRequestComment};
}
return {
- isSupportTextHtml: false,
supportText: translate('iou.expenseCount', {
count: numberOfRequests - numberOfScanningReceipts - numberOfPendingRequests,
scanningReceipts: numberOfScanningReceipts,
@@ -310,11 +306,7 @@ function ReportPreview({
{shouldShowSubtitle && supportText && (
- {isSupportTextHtml ? (
-
- ) : (
- {supportText}
- )}
+ {supportText}
)}
diff --git a/src/components/Search.tsx b/src/components/Search.tsx
index 4ea2c6de0813..3f248c0c72aa 100644
--- a/src/components/Search.tsx
+++ b/src/components/Search.tsx
@@ -1,10 +1,7 @@
import React, {useEffect} from 'react';
-import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
-import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
-import useWindowDimensions from '@hooks/useWindowDimensions';
import * as SearchActions from '@libs/actions/Search';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import * as SearchUtils from '@libs/SearchUtils';
@@ -16,24 +13,8 @@ import ROUTES from '@src/ROUTES';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
import SelectionList from './SelectionList';
+import SearchTableHeader from './SelectionList/SearchTableHeader';
import TableListItemSkeleton from './Skeletons/TableListItemSkeleton';
-import Text from './Text';
-
-/**
- * Todo This is a temporary function that will pick search results from under `snapshot_` key
- * either api needs to be updated to key by `snapshot_hash` or app code calling search data needs to be refactored
- * remove this function once this is properly fixed
- */
-function getCleanSearchResults(searchResults: unknown) {
- if (!searchResults) {
- return {};
- }
-
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- // eslint-disable-next-line no-underscore-dangle,@typescript-eslint/no-unsafe-return
- return searchResults?.data;
-}
type SearchProps = {
query: string;
@@ -41,10 +22,7 @@ type SearchProps = {
function Search({query}: SearchProps) {
const {isOffline} = useNetwork();
- const {translate} = useLocalize();
const styles = useThemeStyles();
- const {isSmallScreenWidth, isMediumScreenWidth} = useWindowDimensions();
- // const [selectedCategories, setSelectedCategories] = useState>({});
useCustomBackHandler();
const hash = SearchUtils.getQueryHash(query);
@@ -58,14 +36,8 @@ function Search({query}: SearchProps) {
SearchActions.search(query);
}, [query, isOffline]);
- const cleanResults = getCleanSearchResults(searchResults);
-
- useEffect(() => {
- SearchActions.addPersonalDetailsFromSearch(cleanResults?.personalDetailsList ?? {});
- }, [cleanResults]);
-
- const isLoading = (!isOffline && isLoadingOnyxValue(searchResultsMeta)) || cleanResults === undefined;
- const shouldShowEmptyState = !isLoading && isEmptyObject(cleanResults);
+ const isLoading = (!isOffline && isLoadingOnyxValue(searchResultsMeta)) || searchResults?.data === undefined;
+ const shouldShowEmptyState = !isLoading && isEmptyObject(searchResults?.data);
if (isLoading) {
return ;
@@ -75,33 +47,6 @@ function Search({query}: SearchProps) {
return ;
}
- const displayNarrowVersion = isMediumScreenWidth || isSmallScreenWidth;
-
- const getListHeader = () => {
- if (displayNarrowVersion) {
- return;
- }
-
- // const showMerchantColumn = ReportUtils.shouldShowMerchantColumn(data);
- const showMerchantColumn = displayNarrowVersion && true;
-
- return (
-
- {/* {translate('common.receipt')} */}
- {translate('common.date')}
- {showMerchantColumn && {translate('common.merchant')} }
- {translate('common.description')}
- {translate('common.from')}
- {translate('common.to')}
- {translate('common.category')}
- {translate('common.tag')}
- {translate('common.total')}
- {translate('common.type')}
- {translate('common.action')}
-
- );
- };
-
const openReport = (reportID?: string) => {
if (!reportID) {
return;
@@ -111,19 +56,17 @@ function Search({query}: SearchProps) {
};
const ListItem = SearchUtils.getListItem();
- const data = SearchUtils.getSections(cleanResults ?? {});
+ const data = SearchUtils.getSections(searchResults?.data ?? {});
+ const shouldShowMerchant = SearchUtils.getShouldShowMerchant(searchResults?.data ?? {});
return (
}
ListItem={ListItem}
sections={[{data, isDisabled: false}]}
onSelectRow={(item) => {
openReport(item.transactionThreadReportID);
}}
- onSelectAll={!displayNarrowVersion ? () => {} : undefined}
- onCheckboxPress={() => {}}
shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]}
/>
diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx
index 848761c9e982..7f7d759c72aa 100644
--- a/src/components/Section/index.tsx
+++ b/src/components/Section/index.tsx
@@ -68,6 +68,9 @@ type SectionProps = ChildrenProps & {
/** Styles to apply to illustration component */
illustrationStyle?: StyleProp;
+ /** Padding for content on large screens */
+ contentPaddingOnLargeScreens?: {padding: number};
+
/** Overlay content to display on top of animation */
overlayContent?: () => ReactNode;
@@ -92,6 +95,7 @@ function Section({
illustration,
illustrationBackgroundColor,
illustrationStyle,
+ contentPaddingOnLargeScreens,
overlayContent,
renderSubtitle,
}: SectionProps) {
@@ -124,7 +128,7 @@ function Section({
{overlayContent?.()}
)}
-
+
{cardLayout === CARD_LAYOUT.ICON_ON_LEFT && (
+
+
+ {translate('common.date')}
+
+
+ {translate(shouldShowMerchant ? 'common.merchant' : 'common.description')}
+
+
+ {translate('common.from')}
+
+
+ {translate('common.to')}
+
+
+ {translate('common.total')}
+
+
+ {translate('common.type')}
+
+
+ {translate('common.action')}
+
+
+
+ );
+}
+
+SearchTableHeader.displayName = 'SearchTableHeader';
+
+export default SearchTableHeader;
diff --git a/src/components/SelectionList/TransactionListItem.tsx b/src/components/SelectionList/TransactionListItem.tsx
index e9e5d01dc89d..3e60aa8c270e 100644
--- a/src/components/SelectionList/TransactionListItem.tsx
+++ b/src/components/SelectionList/TransactionListItem.tsx
@@ -1,24 +1,25 @@
-import {format} from 'date-fns';
-import React, {useCallback} from 'react';
+import React from 'react';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import Avatar from '@components/Avatar';
import Button from '@components/Button';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
-import {usePersonalDetails} from '@components/OnyxProvider';
-import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Text from '@components/Text';
import TextWithTooltip from '@components/TextWithTooltip';
+import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as CurrencyUtils from '@libs/CurrencyUtils';
+import * as TransactionUtils from '@libs/TransactionUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
-import type {SearchTransactionType} from '@src/types/onyx/SearchResults';
+import type {Transaction} from '@src/types/onyx';
+import type {SearchPersonalDetails, SearchPolicyDetails, SearchTransactionType} from '@src/types/onyx/SearchResults';
import BaseListItem from './BaseListItem';
-import type {ListItem, TransactionListItemProps} from './types';
+import type {ListItem, TransactionListItemProps, TransactionListItemType} from './types';
const getTypeIcon = (type?: SearchTransactionType) => {
switch (type) {
@@ -40,120 +41,105 @@ function TransactionListItem({
isDisabled,
canSelectMultiple,
onSelectRow,
- onCheckboxPress,
onDismissError,
shouldPreventDefaultFocusOnSelectRow,
onFocus,
shouldSyncFocus,
}: TransactionListItemProps) {
+ const transactionItem = item as unknown as TransactionListItemType;
const styles = useThemeStyles();
+ const {translate} = useLocalize();
const theme = useTheme();
+ const {isLargeScreenWidth} = useWindowDimensions();
const StyleUtils = useStyleUtils();
- const {isSmallScreenWidth, isMediumScreenWidth} = useWindowDimensions();
- const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT;
- // const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor;
- // const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar;
-
- const typeIcon = getTypeIcon(item?.type as SearchTransactionType);
-
- const handleCheckboxPress = useCallback(() => {
- if (onCheckboxPress) {
- onCheckboxPress(item);
- } else {
- onSelectRow(item);
- }
- }, [item, onCheckboxPress, onSelectRow]);
-
- const displayNarrowVersion = isMediumScreenWidth || isSmallScreenWidth;
- const userFontStyle = displayNarrowVersion ? styles.textMicro : undefined;
+ function getMerchant() {
+ const merchant = TransactionUtils.getMerchant(transactionItem as OnyxEntry);
+ return merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || merchant === CONST.TRANSACTION.DEFAULT_MERCHANT ? '' : merchant;
+ }
- const accountDetails = item.accountID ? personalDetails[item.accountID] : null;
- const managerDetails = item.managerID ? personalDetails[item.managerID] : null;
+ const isFromExpenseReport = transactionItem.reportType === CONST.REPORT.TYPE.EXPENSE;
+ const date = TransactionUtils.getCreated(transactionItem as OnyxEntry, CONST.DATE.MONTH_DAY_ABBR_FORMAT);
+ const amount = TransactionUtils.getAmount(transactionItem as OnyxEntry, isFromExpenseReport);
+ const currency = TransactionUtils.getCurrency(transactionItem as OnyxEntry);
+ const description = TransactionUtils.getDescription(transactionItem as OnyxEntry);
+ const merchant = getMerchant();
+ const typeIcon = getTypeIcon(transactionItem.type);
- const rowButtonElement = (
- {
- onSelectRow(item);
- }}
- small
- pressOnEnter
- />
- );
-
- const amountElement = (
+ const dateCell = (
);
- const categoryElement = (
+ const merchantCell = (
);
- const descriptionElement = (
-
- );
+ const userCell = (participant: SearchPersonalDetails & SearchPolicyDetails) => {
+ const displayName = participant?.name ?? participant?.displayName ?? participant?.login;
+ const avatarURL = participant?.avatarURL ?? participant?.avatar;
+ const isWorkspace = participant?.avatarURL !== undefined;
+ const iconType = isWorkspace ? CONST.ICON_TYPE_WORKSPACE : CONST.ICON_TYPE_AVATAR;
- const dateElement = (
+ return (
+
+
+
+ {displayName}
+
+
+ );
+ };
+
+ const totalCell = (
);
- const fromElement = (
-
-
-
- {accountDetails?.displayName}
-
-
+ const typeCell = (
+
);
- const toElement = (
-
-
-
- {managerDetails?.displayName}
-
-
+ const actionCell = (
+ {
+ onSelectRow(item);
+ }}
+ small
+ pressOnEnter
+ style={[styles.p0]}
+ />
);
- const listItemPressableStyle = [styles.selectionListPressableItemWrapper, item.isSelected && styles.activeComponentBG, isFocused && styles.sidebarLinkActive];
+ const listItemPressableStyle = [styles.selectionListPressableItemWrapper, styles.pv3, item.isSelected && styles.activeComponentBG, isFocused && styles.sidebarLinkActive];
- if (displayNarrowVersion) {
+ if (!isLargeScreenWidth) {
return (
({
isDisabled={isDisabled}
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
- onSelectRow={() => {}}
+ onSelectRow={onSelectRow}
onDismissError={onDismissError}
shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
errors={item.errors}
@@ -176,32 +162,26 @@ function TransactionListItem({
>
{() => (
<>
-
-
- {fromElement}
+
+
+ {userCell(transactionItem.from)}
- {toElement}
+ {userCell(transactionItem.to)}
- {rowButtonElement}
+ {actionCell}
-
-
- {descriptionElement}
- {categoryElement}
-
-
- {amountElement}
-
-
- {dateElement}
+
+ {merchantCell}
+
+ {totalCell}
+
+ {typeCell}
+ {dateCell}
@@ -221,7 +201,7 @@ function TransactionListItem({
isDisabled={isDisabled}
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
- onSelectRow={() => {}}
+ onSelectRow={onSelectRow}
onDismissError={onDismissError}
shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
errors={item.errors}
@@ -232,51 +212,15 @@ function TransactionListItem({
hoverStyle={item.isSelected && styles.activeComponentBG}
>
{() => (
- <>
- {canSelectMultiple && (
-
-
- {item.isSelected && (
-
- )}
-
-
- )}
-
- {dateElement}
- {descriptionElement}
- {fromElement}
- {toElement}
- {categoryElement}
-
-
-
- {amountElement}
-
-
-
- {rowButtonElement}
-
- >
+
+ {dateCell}
+ {merchantCell}
+ {userCell(transactionItem.from)}
+ {userCell(transactionItem.to)}
+ {totalCell}
+ {typeCell}
+ {actionCell}
+
)}
);
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index f53abd7513b9..8a2a1efdd030 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -1,9 +1,11 @@
import type {MutableRefObject, ReactElement, ReactNode} from 'react';
import type {GestureResponderEvent, InputModeOptions, LayoutChangeEvent, SectionListData, StyleProp, TextInput, TextStyle, ViewStyle} from 'react-native';
+import type {ValueOf} from 'type-fest';
import type {MaybePhraseKey} from '@libs/Localize';
import type {BrickRoad} from '@libs/WorkspacesSettingsUtils';
import type CONST from '@src/CONST';
import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon';
+import type {SearchPersonalDetails, SearchPolicyDetails} from '@src/types/onyx/SearchResults';
import type {ReceiptErrors} from '@src/types/onyx/Transaction';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import type IconAsset from '@src/types/utils/IconAsset';
@@ -122,25 +124,67 @@ type ListItem = {
/** What text to show inside the badge (if none present the badge will be omitted) */
badgeText?: string;
+ /** Whether the brick road indicator should be shown */
brickRoadIndicator?: BrickRoad | '' | null;
+};
+
+type TransactionListItemType = ListItem & {
+ /** The ID of the transaction */
+ transactionID: string;
+
+ /** The transaction created date */
+ created: string;
+
+ /** The edited transaction created date */
+ modifiedCreated: string;
+
+ /** The transaction amount */
+ amount: number;
+
+ /** The edited transaction amount */
+ modifiedAmount: number;
+
+ /** The transaction currency */
+ currency: string;
+
+ /** The edited transaction currency */
+ modifiedCurrency: string;
+
+ /** The transaction merchant */
+ merchant: string;
+
+ /** The edited transaction merchant */
+ modifiedMerchant: string;
+
+ /** The receipt object */
+ receipt?: {source?: string};
- managerID?: number;
+ /** The personal details of the user requesting money */
+ from: SearchPersonalDetails & SearchPolicyDetails;
- amount?: number;
+ /** The personal details of the user paying the request */
+ to: SearchPersonalDetails & SearchPolicyDetails;
- currency?: string;
+ /** The transaction tag */
+ tag: string;
- tag?: string;
+ /** The transaction description */
+ comment: {comment: string};
- description?: string;
+ /** The transaction category */
+ category: string;
- category?: string;
+ /** The type of request */
+ type: ValueOf;
- created?: string;
+ /** The type of report the transaction is associated with */
+ reportType: string;
- merchant?: string;
+ /** The ID of the policy the transaction is associated with */
+ policyID: string;
- type?: string;
+ /** Whether we should show the merchant column */
+ shouldShowMerchant: boolean;
};
type ListItemProps = CommonListItemProps & {
@@ -406,4 +450,5 @@ export type {
TransactionListItemProps,
UserListItemProps,
ValidListItem,
+ TransactionListItemType,
};
diff --git a/src/hooks/useNetwork.ts b/src/hooks/useNetwork.ts
index 778fed200b7b..950d0592b59c 100644
--- a/src/hooks/useNetwork.ts
+++ b/src/hooks/useNetwork.ts
@@ -12,8 +12,7 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {
const callback = useRef(onReconnect);
callback.current = onReconnect;
- const {networkStatus} = useContext(NetworkContext) ?? {...CONST.DEFAULT_NETWORK_DATA, networkStatus: CONST.NETWORK.NETWORK_STATUS.UNKNOWN};
- const isOffline = networkStatus === CONST.NETWORK.NETWORK_STATUS.OFFLINE;
+ const {isOffline, networkStatus} = useContext(NetworkContext) ?? {...CONST.DEFAULT_NETWORK_DATA, networkStatus: CONST.NETWORK.NETWORK_STATUS.UNKNOWN};
const prevOfflineStatusRef = useRef(isOffline);
useEffect(() => {
// If we were offline before and now we are not offline then we just reconnected
@@ -30,5 +29,6 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {
prevOfflineStatusRef.current = isOffline;
}, [isOffline]);
- return {isOffline};
+ // If the network status is undefined, we don't treat it as offline. Otherwise, we utilize the isOffline prop.
+ return {isOffline: networkStatus === CONST.NETWORK.NETWORK_STATUS.UNKNOWN ? false : isOffline};
}
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 8db64180f789..179ad777234d 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -330,6 +330,7 @@ export default {
clear: 'Clear',
type: 'Type',
action: 'Action',
+ expenses: 'Expenses',
},
location: {
useCurrent: 'Use current location',
@@ -1838,6 +1839,28 @@ export default {
session: {
offlineMessageRetry: "Looks like you're offline. Please check your connection and try again.",
},
+ travel: {
+ header: 'Book travel',
+ title: 'Travel smart',
+ subtitle: 'Use Expensify Travel to get the best travel offers and manage all your business expenses in one place.',
+ features: {
+ saveMoney: 'Save money on your bookings',
+ alerts: 'Get realtime updates and alerts',
+ },
+ bookTravel: 'Book travel',
+ termsAndConditions: {
+ header: 'Before we continue...',
+ title: 'Please read the Terms & Conditions for travel',
+ subtitle: 'To enable travel on your workspace you must agree to our ',
+ termsconditions: 'terms & conditions',
+ travelTermsAndConditions: 'terms & conditions',
+ helpDocIntro: 'Check out this ',
+ helpDocOutro: 'for more information or reach out to Concierge or your Account Manager.',
+ helpDoc: 'Help Doc',
+ agree: 'I agree to the travel ',
+ error: 'You must accept the Terms & Conditions for travel to continue',
+ },
+ },
workspace: {
common: {
card: 'Cards',
@@ -2660,7 +2683,8 @@ export default {
checkForUpdatesModal: {
available: {
title: 'Update Available',
- message: "The new version will be available shortly. We'll notify you when we're ready to update.",
+ message: ({isSilentUpdating}: {isSilentUpdating: boolean}) =>
+ `The new version will be available shortly.${!isSilentUpdating ? " We'll notify you when we're ready to update." : ''}`,
soundsGood: 'Sounds good',
},
notAvailable: {
@@ -2977,4 +3001,7 @@ export default {
offline:
"You appear to be offline. Unfortunately, Expensify Classic doesn't work offline, but New Expensify does. If you prefer to use Expensify Classic, try again when you have an internet connection.",
},
+ systemMessage: {
+ mergedWithCashTransaction: 'matched a receipt to this transaction.',
+ },
} satisfies TranslationBase;
diff --git a/src/languages/es.ts b/src/languages/es.ts
index a156b0073442..b7c32027c40a 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -320,6 +320,7 @@ export default {
clear: 'Borrar',
type: 'Tipo',
action: 'Acción',
+ expenses: 'Gastos',
},
connectionComplete: {
title: 'Conexión Completa',
@@ -1862,6 +1863,28 @@ export default {
session: {
offlineMessageRetry: 'Parece que estás desconectado. Por favor, comprueba tu conexión e inténtalo de nuevo.',
},
+ travel: {
+ header: 'Reservar viajes',
+ title: 'Viaja de forma inteligente',
+ subtitle: 'Utiliza Expensify Travel para obtener las mejores ofertas de viaje y gestionar todos los gastos de tu negocio en un solo lugar.',
+ features: {
+ saveMoney: 'Ahorra dinero en tus reservas',
+ alerts: 'Obtén actualizaciones y alertas en tiempo real',
+ },
+ bookTravel: 'Reservar viajes',
+ termsAndConditions: {
+ header: 'Antes de continuar...',
+ title: 'Por favor, lee los Términos y condiciones para reservar viajes',
+ subtitle: 'Para permitir la opción de reservar viajes en su espacio de trabajo debe aceptar nuestros ',
+ termsconditions: 'términos y condiciones',
+ travelTermsAndConditions: 'términos y condiciones de viaje',
+ helpDocIntro: 'Consulta este ',
+ helpDocOutro: 'para obtener más información o comunícate con Concierge o tu gestor de cuentas.',
+ helpDoc: 'documento de ayuda',
+ agree: 'Acepto los ',
+ error: 'Debes aceptar los Términos y condiciones para que el viaje continúe',
+ },
+ },
workspace: {
common: {
card: 'Tarjetas',
@@ -2700,7 +2723,8 @@ export default {
checkForUpdatesModal: {
available: {
title: 'Actualización disponible',
- message: 'La nueva versión estará disponible dentro de poco. Te notificaremos cuando esté lista.',
+ message: ({isSilentUpdating}: {isSilentUpdating: boolean}) =>
+ `La nueva versión estará disponible dentro de poco.${isSilentUpdating ? ' Te notificaremos cuando esté lista.' : ''}`,
soundsGood: 'Suena bien',
},
notAvailable: {
@@ -3481,4 +3505,7 @@ export default {
offline:
'Parece que estás desconectado. Desafortunadamente, Expensify Classic no funciona sin conexión, pero New Expensify sí. Si prefieres utilizar Expensify Classic, inténtalo de nuevo cuando tengas conexión a internet.',
},
+ systemMessage: {
+ mergedWithCashTransaction: 'encontró un recibo para esta transacción.',
+ },
} satisfies EnglishTranslation;
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index cd85cdfbe401..c61e1278ff8a 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -213,6 +213,7 @@ const WRITE_COMMANDS = {
CATEGORIZE_TRACKED_EXPENSE: 'CategorizeTrackedExpense',
SHARE_TRACKED_EXPENSE: 'ShareTrackedExpense',
LEAVE_POLICY: 'LeavePolicy',
+ ACCEPT_SPOTNANA_TERMS: 'AcceptSpotnanaTerms',
SEND_INVOICE: 'SendInvoice',
} as const;
@@ -427,6 +428,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE]: Parameters.CategorizeTrackedExpenseParams;
[WRITE_COMMANDS.SHARE_TRACKED_EXPENSE]: Parameters.ShareTrackedExpenseParams;
[WRITE_COMMANDS.LEAVE_POLICY]: Parameters.LeavePolicyParams;
+ [WRITE_COMMANDS.ACCEPT_SPOTNANA_TERMS]: EmptyObject;
[WRITE_COMMANDS.SEND_INVOICE]: Parameters.SendInvoiceParams;
};
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index bef1368f9c2a..22c5a7d907cc 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -533,8 +533,8 @@ function getStatusUntilDate(inputDate: string): string {
const now = new Date();
const endOfToday = endOfDay(now);
- // If the date is equal to the end of today
- if (isSameDay(input, endOfToday)) {
+ // If the date is adjusted to the following day
+ if (isSameSecond(input, endOfToday)) {
return translateLocal('statusPage.untilTomorrow');
}
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 965e8341bf10..0190434f73a3 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -28,6 +28,7 @@ import type {
SplitDetailsNavigatorParamList,
TaskDetailsNavigatorParamList,
TeachersUniteNavigatorParamList,
+ TravelNavigatorParamList,
WalletStatementNavigatorParamList,
WorkspaceSwitcherNavigatorParamList,
} from '@navigation/types';
@@ -96,6 +97,11 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/PersonalDetails/StateSelectionPage').default as React.ComponentType,
});
+const TravelModalStackNavigator = createModalStackNavigator({
+ [SCREENS.TRAVEL.MY_TRIPS]: () => require('../../../../pages/Travel/MyTripsPage').default as React.ComponentType,
+ [SCREENS.TRAVEL.TCS]: () => require('../../../../pages/Travel/TravelTerms').default as React.ComponentType,
+});
+
const SplitDetailsModalStackNavigator = createModalStackNavigator({
[SCREENS.SPLIT_DETAILS.ROOT]: () => require('../../../../pages/iou/SplitBillDetailsPage').default as React.ComponentType,
});
@@ -369,6 +375,7 @@ export {
PrivateNotesModalStackNavigator,
ProfileModalStackNavigator,
ReferralModalStackNavigator,
+ TravelModalStackNavigator,
WorkspaceSwitcherModalStackNavigator,
ReimbursementAccountModalStackNavigator,
ReportDetailsModalStackNavigator,
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
index c3cff62eefe2..1d595f05ee88 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
@@ -133,6 +133,10 @@ function RightModalNavigator({navigation}: RightModalNavigatorProps) {
name="ProcessMoneyRequestHold"
component={ModalStackNavigators.ProcessMoneyRequestHoldStackNavigator}
/>
+
| undefined;
const routes = navigationState?.routes;
const currentRoute = routes?.[navigationState?.index ?? 0];
-
+ // When we are redirected to the Settings tab from the OldDot, we don't want to call the Welcome.show() method.
+ // To prevent this, the value of the bottomTabRoute?.name is checked here
if (Boolean(currentRoute && currentRoute.name !== NAVIGATORS.BOTTOM_TAB_NAVIGATOR && currentRoute.name !== NAVIGATORS.CENTRAL_PANE_NAVIGATOR) || Session.isAnonymousUser()) {
return;
}
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index a41ac031c55b..71f1bb3e761b 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -693,6 +693,12 @@ const config: LinkingOptions['config'] = {
[SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: ROUTES.PROCESS_MONEY_REQUEST_HOLD,
},
},
+ [SCREENS.RIGHT_MODAL.TRAVEL]: {
+ screens: {
+ [SCREENS.TRAVEL.MY_TRIPS]: ROUTES.TRAVEL_MY_TRIPS,
+ [SCREENS.TRAVEL.TCS]: ROUTES.TRAVEL_TCS,
+ },
+ },
[SCREENS.RIGHT_MODAL.SEARCH_REPORT]: {
screens: {
[SCREENS.SEARCH.REPORT_RHP]: ROUTES.SEARCH_REPORT.route,
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index b3e83985d0e2..42790919b986 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -716,6 +716,12 @@ type RightModalNavigatorParamList = {
[SCREENS.RIGHT_MODAL.PROCESS_MONEY_REQUEST_HOLD]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.REFERRAL]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: NavigatorScreenParams;
+ [SCREENS.RIGHT_MODAL.TRAVEL]: NavigatorScreenParams;
+ [SCREENS.RIGHT_MODAL.SEARCH_REPORT]: NavigatorScreenParams;
+};
+
+type TravelNavigatorParamList = {
+ [SCREENS.TRAVEL.MY_TRIPS]: undefined;
[SCREENS.RIGHT_MODAL.SEARCH_REPORT]: NavigatorScreenParams;
};
@@ -940,6 +946,7 @@ export type {
State,
StateOrRoute,
SwitchPolicyIDParams,
+ TravelNavigatorParamList,
TaskDetailsNavigatorParamList,
TeachersUniteNavigatorParamList,
WalletStatementNavigatorParamList,
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 3bebd5a0813a..b599fdc6d0f1 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -266,20 +266,20 @@ Onyx.connect({
}
const reportID = CollectionUtils.extractCollectionItemID(key);
allReportActions[reportID] = actions;
- const sortedReportActions = ReportActionUtils.getSortedReportActions(Object.values(actions), true);
+ let sortedReportActions = ReportActionUtils.getSortedReportActions(Object.values(actions), true);
allSortedReportActions[reportID] = sortedReportActions;
+
+ const transactionThreadReportID = ReportActionUtils.getOneTransactionThreadReportID(reportID, allReportActions[reportID], true);
+ if (transactionThreadReportID) {
+ sortedReportActions = ReportActionUtils.getCombinedReportActions(allSortedReportActions[reportID], allSortedReportActions[transactionThreadReportID]);
+ }
+
lastReportActions[reportID] = sortedReportActions[0];
// The report is only visible if it is the last action not deleted that
// does not match a closed or created state.
- const reportActionsForDisplay = sortedReportActions.filter(
- (reportAction, actionKey) =>
- ReportActionUtils.shouldReportActionBeVisible(reportAction, actionKey) &&
- !ReportActionUtils.isWhisperAction(reportAction) &&
- reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED &&
- reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
- );
- visibleReportActionItems[reportID] = reportActionsForDisplay[reportActionsForDisplay.length - 1];
+ const reportActionsForDisplay = sortedReportActions.filter((reportAction) => ReportActionUtils.shouldReportActionBeVisibleAsLastAction(reportAction));
+ visibleReportActionItems[reportID] = reportActionsForDisplay[0];
},
});
@@ -559,7 +559,8 @@ function getAlternateText(
* Get the last message text from the report directly or from other sources for special cases.
*/
function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails: Partial | null, policy?: OnyxEntry): string {
- const lastReportAction = allSortedReportActions[report?.reportID ?? '']?.find((reportAction) => ReportActionUtils.shouldReportActionBeVisibleAsLastAction(reportAction)) ?? null;
+ const lastReportAction = visibleReportActionItems[report?.reportID ?? ''] ?? null;
+
// some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action
const lastOriginalReportAction = lastReportActions[report?.reportID ?? ''] ?? null;
let lastMessageTextFromReport = '';
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index d24b249df086..f886558c54f6 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -143,7 +143,7 @@ const isPolicyEmployee = (policyID: string, policies: OnyxCollection): b
/**
* Checks if the current user is an owner (creator) of the policy.
*/
-const isPolicyOwner = (policy: OnyxEntry, currentUserAccountID: number): boolean => policy?.ownerAccountID === currentUserAccountID;
+const isPolicyOwner = (policy: OnyxEntry | EmptyObject, currentUserAccountID: number): boolean => policy?.ownerAccountID === currentUserAccountID;
/**
* Create an object mapping member emails to their accountIDs. Filter for members without errors, and get the login email from the personalDetail object using the accountID.
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index dc07f31c1067..64c4ed61f7fd 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -225,46 +225,6 @@ function isTransactionThread(parentReportAction: OnyxEntry | Empty
);
}
-/**
- * Returns the reportID for the transaction thread associated with a report by iterating over the reportActions and identifying the IOU report actions with a childReportID. Returns a reportID if there is exactly one transaction thread for the report, and null otherwise.
- */
-function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined): string | null {
- // If the report is not an IOU, Expense report or an Invoice, it shouldn't be treated as one-transaction report.
- const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
- if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) {
- return null;
- }
-
- const reportActionsArray = Object.values(reportActions ?? {});
-
- if (!reportActionsArray.length) {
- return null;
- }
-
- // Get all IOU report actions for the report.
- const iouRequestTypes: Array> = [
- CONST.IOU.REPORT_ACTION_TYPE.CREATE,
- CONST.IOU.REPORT_ACTION_TYPE.SPLIT,
- CONST.IOU.REPORT_ACTION_TYPE.PAY,
- CONST.IOU.REPORT_ACTION_TYPE.TRACK,
- ];
- const iouRequestActions = reportActionsArray.filter(
- (action) =>
- action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
- (iouRequestTypes.includes(action.originalMessage.type) ?? []) &&
- action.childReportID &&
- (Boolean(action.originalMessage.IOUTransactionID) || (action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && (isOffline ?? isNetworkOffline))),
- );
-
- // If we don't have any IOU request actions, or we have more than one IOU request actions, this isn't a oneTransaction report
- if (!iouRequestActions.length || iouRequestActions.length > 1) {
- return null;
- }
-
- // Ensure we have a childReportID associated with the IOU report action
- return iouRequestActions[0].childReportID ?? null;
-}
-
/**
* Sort an array of reportActions by their created timestamp first, and reportActionID second
* This gives us a stable order even in the case of multiple reportActions created on the same millisecond
@@ -301,6 +261,27 @@ function getSortedReportActions(reportActions: ReportAction[] | null, shouldSort
return sortedActions;
}
+/**
+ * Returns a sorted and filtered list of report actions from a report and it's associated child
+ * transaction thread report in order to correctly display reportActions from both reports in the one-transaction report view.
+ */
+function getCombinedReportActions(reportActions: ReportAction[], transactionThreadReportActions: ReportAction[]): ReportAction[] {
+ if (isEmptyObject(transactionThreadReportActions)) {
+ return reportActions;
+ }
+
+ // Filter out the created action from the transaction thread report actions, since we already have the parent report's created action in `reportActions`
+ const filteredTransactionThreadReportActions = transactionThreadReportActions?.filter((action) => action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED);
+
+ // Filter out request and send money request actions because we don't want to show any preview actions for one transaction reports
+ const filteredReportActions = [...reportActions, ...filteredTransactionThreadReportActions].filter((action) => {
+ const actionType = (action as OriginalMessageIOU).originalMessage?.type ?? '';
+ return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE && actionType !== CONST.IOU.REPORT_ACTION_TYPE.TRACK && !isSentMoneyReportAction(action);
+ });
+
+ return getSortedReportActions(filteredReportActions, true);
+}
+
/**
* Returns the largest gapless range of reportActions including a the provided reportActionID, where a "gap" is defined as a reportAction's `previousReportActionID` not matching the previous reportAction in the sortedReportActions array.
* See unit tests for example of inputs and expected outputs.
@@ -821,6 +802,67 @@ function isTaskAction(reportAction: OnyxEntry): boolean {
);
}
+/**
+ * Gets the reportID for the transaction thread associated with a report by iterating over the reportActions and identifying the IOU report actions.
+ * Returns a reportID if there is exactly one transaction thread for the report, and null otherwise.
+ */
+function getOneTransactionThreadReportID(
+ reportID: string,
+ reportActions: OnyxEntry | ReportAction[],
+ skipReportTypeCheck: boolean | undefined = undefined,
+ isOffline: boolean | undefined = undefined,
+): string | null {
+ if (!skipReportTypeCheck) {
+ // If the report is not an IOU, Expense report, or Invoice, it shouldn't be treated as one-transaction report.
+ const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
+ if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) {
+ return null;
+ }
+ }
+
+ const reportActionsArray = Object.values(reportActions ?? {});
+ if (!reportActionsArray.length) {
+ return null;
+ }
+
+ // Get all IOU report actions for the report.
+ const iouRequestTypes: Array> = [
+ CONST.IOU.REPORT_ACTION_TYPE.CREATE,
+ CONST.IOU.REPORT_ACTION_TYPE.SPLIT,
+ CONST.IOU.REPORT_ACTION_TYPE.PAY,
+ CONST.IOU.REPORT_ACTION_TYPE.TRACK,
+ ];
+
+ const iouRequestActions = reportActionsArray.filter(
+ (action) =>
+ action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
+ (iouRequestTypes.includes(action.originalMessage.type) ?? []) &&
+ action.childReportID &&
+ // Include deleted IOU reportActions if:
+ // - they have an assocaited IOU transaction ID or
+ // - they have visibile childActions (like comments) that we'd want to display
+ // - the action is pending deletion and the user is offline
+ (Boolean(action.originalMessage.IOUTransactionID) ||
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ (isMessageDeleted(action) && action.childVisibleActionCount) ||
+ (action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && (isOffline ?? isNetworkOffline))),
+ );
+
+ // If we don't have any IOU request actions, or we have more than one IOU request actions, this isn't a oneTransaction report
+ if (!iouRequestActions.length || iouRequestActions.length > 1) {
+ return null;
+ }
+
+ // If there's only one IOU request action associated with the report but it's been deleted, then we don't consider this a oneTransaction report
+ // and want to display it using the standard view
+ if (((iouRequestActions[0] as OriginalMessageIOU).originalMessage?.deleted ?? '') !== '') {
+ return null;
+ }
+
+ // Ensure we have a childReportID associated with the IOU report action
+ return iouRequestActions[0].childReportID ?? null;
+}
+
/**
* When we delete certain reports, we want to check whether there are any visible actions left to display.
* If there are no visible actions left (including system messages), we can hide the report from view entirely
@@ -1142,6 +1184,7 @@ export {
isApprovedOrSubmittedReportAction,
getReportPreviewAction,
getSortedReportActions,
+ getCombinedReportActions,
getSortedReportActionsForDisplay,
isConsecutiveActionMadeByPreviousActor,
isCreatedAction,
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 9c5e437a874e..8dd04d168717 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -6344,7 +6344,7 @@ function hasActionsWithErrors(reportID: string): boolean {
return Object.values(reportActions).some((action) => !isEmptyObject(action.errors));
}
-function canLeavePolicyExpenseChat(report: OnyxEntry, policy: OnyxEntry): boolean {
+function canLeavePolicyExpenseChat(report: OnyxEntry, policy: OnyxEntry | EmptyObject): boolean {
return isPolicyExpenseChat(report) && !(PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPolicyOwner(policy, currentUserAccountID ?? -1) || isReportOwner(report));
}
diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts
index 97809ab31041..570a8e780f5a 100644
--- a/src/libs/SearchUtils.ts
+++ b/src/libs/SearchUtils.ts
@@ -1,13 +1,36 @@
import TransactionListItem from '@components/SelectionList/TransactionListItem';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
import type {SearchTransaction} from '@src/types/onyx/SearchResults';
import * as UserUtils from './UserUtils';
+function getShouldShowMerchant(data: OnyxTypes.SearchResults['data']): boolean {
+ return Object.values(data).some((item) => {
+ const merchant = item.modifiedMerchant ? item.modifiedMerchant : item.merchant ?? '';
+ return merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchant !== CONST.TRANSACTION.DEFAULT_MERCHANT;
+ });
+}
+
function getTransactionsSections(data: OnyxTypes.SearchResults['data']): SearchTransaction[] {
+ const shouldShowMerchant = getShouldShowMerchant(data);
return Object.entries(data)
.filter(([key]) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION))
- .map(([, value]) => value);
+ .map(([, value]) => {
+ const isExpenseReport = value.reportType === CONST.REPORT.TYPE.EXPENSE;
+ return {
+ ...value,
+ from: data.personalDetailsList?.[value.accountID],
+ to: isExpenseReport ? data[`${ONYXKEYS.COLLECTION.POLICY}${value.policyID}`] : data.personalDetailsList?.[value.managerID],
+ shouldShowMerchant,
+ keyForList: value.transactionID,
+ };
+ })
+ .sort((a, b) => {
+ const createdA = a.modifiedCreated ? a.modifiedCreated : a.created;
+ const createdB = b.modifiedCreated ? b.modifiedCreated : b.created;
+ return createdB > createdA ? 1 : -1;
+ });
}
const searchTypeToItemMap = {
@@ -33,4 +56,4 @@ function getQueryHash(query: string): number {
return UserUtils.hashText(query, 2 ** 32);
}
-export {getListItem, getQueryHash, getSections};
+export {getListItem, getQueryHash, getSections, getShouldShowMerchant};
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index c0d0c9020a64..74fab75dcc18 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -36,13 +36,7 @@ Onyx.connect({
// The report is only visible if it is the last action not deleted that
// does not match a closed or created state.
- const reportActionsForDisplay = actionsArray.filter(
- (reportAction, actionKey) =>
- ReportActionsUtils.shouldReportActionBeVisible(reportAction, actionKey) &&
- !ReportActionsUtils.isWhisperAction(reportAction) &&
- reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED &&
- reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
- );
+ const reportActionsForDisplay = actionsArray.filter((reportAction) => ReportActionsUtils.shouldReportActionBeVisibleAsLastAction(reportAction));
visibleReportActionItems[reportID] = reportActionsForDisplay[reportActionsForDisplay.length - 1];
},
diff --git a/src/libs/actions/AppUpdate/updateApp/index.desktop.ts b/src/libs/actions/AppUpdate/updateApp/index.desktop.ts
index fb3a7d649baa..5c1ecbe05742 100644
--- a/src/libs/actions/AppUpdate/updateApp/index.desktop.ts
+++ b/src/libs/actions/AppUpdate/updateApp/index.desktop.ts
@@ -1,6 +1,5 @@
-import {Linking} from 'react-native';
-import CONST from '@src/CONST';
+import ELECTRON_EVENTS from '@desktop/ELECTRON_EVENTS';
export default function updateApp() {
- Linking.openURL(CONST.APP_DOWNLOAD_LINKS.DESKTOP);
+ window.electron.send(ELECTRON_EVENTS.SILENT_UPDATE);
}
diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts
index 2f5704a683fc..03179fae93cc 100644
--- a/src/libs/actions/Search.ts
+++ b/src/libs/actions/Search.ts
@@ -1,22 +1,13 @@
-import Onyx from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
import * as API from '@libs/API';
import {READ_COMMANDS} from '@libs/API/types';
import * as SearchUtils from '@libs/SearchUtils';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {PersonalDetailsList} from '@src/types/onyx';
function search(query: string) {
const hash = SearchUtils.getQueryHash(query);
API.read(READ_COMMANDS.SEARCH, {query, hash});
}
-function addPersonalDetailsFromSearch(personalDetails: OnyxEntry) {
- return Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails);
-}
-
export {
// eslint-disable-next-line import/prefer-default-export
- addPersonalDetailsFromSearch,
search,
};
diff --git a/src/libs/actions/Travel.ts b/src/libs/actions/Travel.ts
new file mode 100644
index 000000000000..02affae0fe67
--- /dev/null
+++ b/src/libs/actions/Travel.ts
@@ -0,0 +1,26 @@
+import type {OnyxUpdate} from 'react-native-onyx';
+import * as API from '@libs/API';
+import {WRITE_COMMANDS} from '@libs/API/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+/**
+ * Accept Spotnana terms and conditions to receive a proper token used for authenticating further actions
+ */
+function acceptSpotnanaTerms() {
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: 'merge',
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ travelSettings: {
+ hasAcceptedTerms: true,
+ },
+ },
+ },
+ ];
+
+ API.write(WRITE_COMMANDS.ACCEPT_SPOTNANA_TERMS, {}, {successData});
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export {acceptSpotnanaTerms};
diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts
index d723d3a5cd6d..3f4c50924e9a 100644
--- a/src/libs/actions/Welcome.ts
+++ b/src/libs/actions/Welcome.ts
@@ -69,6 +69,10 @@ function setOnboardingPurposeSelected(value: OnboardingPurposeType) {
Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null);
}
+function setOnboardingAdminsChatReportID(adminsChatReportID?: string) {
+ Onyx.set(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID, adminsChatReportID ?? null);
+}
+
Onyx.connect({
key: ONYXKEYS.NVP_ONBOARDING,
initWithStoredValues: false,
@@ -130,4 +134,4 @@ function resetAllChecks() {
isLoadingReportData = true;
}
-export {onServerDataReady, isOnboardingFlowCompleted, setOnboardingPurposeSelected, resetAllChecks};
+export {onServerDataReady, isOnboardingFlowCompleted, setOnboardingPurposeSelected, resetAllChecks, setOnboardingAdminsChatReportID};
diff --git a/src/libs/getDownloadFolderPathSuffixForIOS.ts b/src/libs/getDownloadFolderPathSuffixForIOS.ts
new file mode 100644
index 000000000000..3355357bca90
--- /dev/null
+++ b/src/libs/getDownloadFolderPathSuffixForIOS.ts
@@ -0,0 +1,24 @@
+import CONST from '@src/CONST';
+
+function getDownloadFolderPathSuffixForIOS(environment: string) {
+ let folderSuffix = '';
+
+ switch (environment) {
+ case CONST.ENVIRONMENT.PRODUCTION:
+ folderSuffix = '';
+ break;
+ case CONST.ENVIRONMENT.ADHOC:
+ folderSuffix = CONST.ENVIRONMENT_SUFFIX.ADHOC;
+ break;
+ case CONST.ENVIRONMENT.DEV:
+ folderSuffix = CONST.ENVIRONMENT_SUFFIX.DEV;
+ break;
+ default:
+ folderSuffix = '';
+ break;
+ }
+
+ return folderSuffix;
+}
+
+export default getDownloadFolderPathSuffixForIOS;
diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
index 9fdb78c91feb..7fcb5c1c5de6 100644
--- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
+++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
@@ -22,15 +22,14 @@ import * as ValidationUtils from '@libs/ValidationUtils';
import variables from '@styles/variables';
import * as PersonalDetails from '@userActions/PersonalDetails';
import * as Report from '@userActions/Report';
+import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import INPUT_IDS from '@src/types/form/DisplayNameForm';
import type {BaseOnboardingPersonalDetailsOnyxProps, BaseOnboardingPersonalDetailsProps} from './types';
-const OPEN_WORK_PAGE_PURPOSES = [CONST.ONBOARDING_CHOICES.MANAGE_TEAM];
-
-function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNativeStyles, onboardingPurposeSelected}: BaseOnboardingPersonalDetailsProps) {
+function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNativeStyles, onboardingPurposeSelected, onboardingAdminsChatReportID}: BaseOnboardingPersonalDetailsProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
@@ -51,17 +50,18 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat
return;
}
- if (OPEN_WORK_PAGE_PURPOSES.includes(onboardingPurposeSelected)) {
- Navigation.navigate(ROUTES.ONBOARDING_WORK);
+ Report.completeOnboarding(
+ onboardingPurposeSelected,
+ CONST.ONBOARDING_MESSAGES[onboardingPurposeSelected],
+ {
+ login: currentUserPersonalDetails.login ?? '',
+ firstName,
+ lastName,
+ },
+ onboardingAdminsChatReportID ?? undefined,
+ );
- return;
- }
-
- Report.completeOnboarding(onboardingPurposeSelected, CONST.ONBOARDING_MESSAGES[onboardingPurposeSelected], {
- login: currentUserPersonalDetails.login ?? '',
- firstName,
- lastName,
- });
+ Welcome.setOnboardingAdminsChatReportID();
Navigation.dismissModal();
@@ -79,7 +79,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat
Navigation.navigate(ROUTES.WELCOME_VIDEO_ROOT);
}, variables.welcomeVideoDelay);
},
- [currentUserPersonalDetails.login, isSmallScreenWidth, onboardingPurposeSelected],
+ [currentUserPersonalDetails.login, isSmallScreenWidth, onboardingPurposeSelected, onboardingAdminsChatReportID],
);
const validate = (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => {
@@ -121,7 +121,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat
;
type BaseOnboardingPersonalDetailsOnyxProps = {
/** Saved onboarding purpose selected by the user */
onboardingPurposeSelected: OnyxEntry;
+
+ /** Saved onboarding admin chat report ID */
+ onboardingAdminsChatReportID: OnyxEntry;
};
type BaseOnboardingPersonalDetailsProps = WithCurrentUserPersonalDetailsProps &
diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
index ced19a99d493..fd328cc26cda 100644
--- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
+++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
@@ -75,6 +75,11 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on
return;
}
+ if (selectedPurpose === CONST.ONBOARDING_CHOICES.MANAGE_TEAM) {
+ Navigation.navigate(ROUTES.ONBOARDING_WORK);
+ return;
+ }
+
Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS);
}, [selectedPurpose]);
@@ -86,8 +91,8 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on
title: translate(translationKey),
icon: menuIcons[choice],
displayInDefaultIconColor: true,
- iconWidth: variables.purposeMenuIconSize,
- iconHeight: variables.purposeMenuIconSize,
+ iconWidth: variables.menuIconSize,
+ iconHeight: variables.menuIconSize,
iconStyles: [styles.mh3],
wrapperStyle: [styles.purposeMenuItem, isSelected && styles.purposeMenuItemSelected],
hoverAndPressStyle: [styles.purposeMenuItemSelected],
diff --git a/src/pages/OnboardingWork/BaseOnboardingWork.tsx b/src/pages/OnboardingWork/BaseOnboardingWork.tsx
index 151c1bb35ea2..8fdbed73111e 100644
--- a/src/pages/OnboardingWork/BaseOnboardingWork.tsx
+++ b/src/pages/OnboardingWork/BaseOnboardingWork.tsx
@@ -9,7 +9,6 @@ import KeyboardAvoidingView from '@components/KeyboardAvoidingView';
import OfflineIndicator from '@components/OfflineIndicator';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
-import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape';
import useLocalize from '@hooks/useLocalize';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
@@ -18,16 +17,17 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as ValidationUtils from '@libs/ValidationUtils';
-import variables from '@styles/variables';
import * as Policy from '@userActions/Policy';
-import * as Report from '@userActions/Report';
+import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import INPUT_IDS from '@src/types/form/WorkForm';
import type {BaseOnboardingWorkOnyxProps, BaseOnboardingWorkProps} from './types';
-function BaseOnboardingWork({currentUserPersonalDetails, shouldUseNativeStyles, onboardingPurposeSelected}: BaseOnboardingWorkProps) {
+const OPEN_WORK_PAGE_PURPOSES = [CONST.ONBOARDING_CHOICES.MANAGE_TEAM];
+
+function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected}: BaseOnboardingWorkProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
@@ -40,39 +40,12 @@ function BaseOnboardingWork({currentUserPersonalDetails, shouldUseNativeStyles,
if (!onboardingPurposeSelected) {
return;
}
-
const work = values.work.trim();
-
const {adminsChatReportID} = Policy.createWorkspace(undefined, true, work);
-
- Report.completeOnboarding(
- onboardingPurposeSelected,
- CONST.ONBOARDING_MESSAGES[onboardingPurposeSelected],
- {
- login: currentUserPersonalDetails.login ?? '',
- firstName: currentUserPersonalDetails.firstName ?? '',
- lastName: currentUserPersonalDetails.lastName ?? '',
- },
- adminsChatReportID,
- );
-
- Navigation.dismissModal();
-
- // Only navigate to concierge chat when central pane is visible
- // Otherwise stay on the chats screen.
- if (isSmallScreenWidth) {
- Navigation.navigate(ROUTES.HOME);
- } else {
- Report.navigateToConciergeChat();
- }
-
- // Small delay purely due to design considerations,
- // no special technical reasons behind that.
- setTimeout(() => {
- Navigation.navigate(ROUTES.WELCOME_VIDEO_ROOT);
- }, variables.welcomeVideoDelay);
+ Welcome.setOnboardingAdminsChatReportID(adminsChatReportID);
+ Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS);
},
- [currentUserPersonalDetails.firstName, currentUserPersonalDetails.lastName, currentUserPersonalDetails.login, isSmallScreenWidth, onboardingPurposeSelected],
+ [onboardingPurposeSelected],
);
const validate = (values: FormOnyxValues<'onboardingWorkForm'>) => {
@@ -96,7 +69,7 @@ function BaseOnboardingWork({currentUserPersonalDetails, shouldUseNativeStyles,
({
- onboardingPurposeSelected: {
- key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED,
- },
- })(BaseOnboardingWork),
-);
+export default withOnyx({
+ onboardingPurposeSelected: {
+ key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED,
+ },
+})(BaseOnboardingWork);
diff --git a/src/pages/OnboardingWork/types.ts b/src/pages/OnboardingWork/types.ts
index 5bef8048628d..954c8c15b31d 100644
--- a/src/pages/OnboardingWork/types.ts
+++ b/src/pages/OnboardingWork/types.ts
@@ -1,5 +1,4 @@
import type {OnyxEntry} from 'react-native-onyx';
-import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
import type {OnboardingPurposeType} from '@src/CONST';
type OnboardingWorkProps = Record;
@@ -9,10 +8,9 @@ type BaseOnboardingWorkOnyxProps = {
onboardingPurposeSelected: OnyxEntry;
};
-type BaseOnboardingWorkProps = WithCurrentUserPersonalDetailsProps &
- BaseOnboardingWorkOnyxProps & {
- /* Whether to use native styles tailored for native devices */
- shouldUseNativeStyles: boolean;
- };
+type BaseOnboardingWorkProps = BaseOnboardingWorkOnyxProps & {
+ /* Whether to use native styles tailored for native devices */
+ shouldUseNativeStyles: boolean;
+};
export type {OnboardingWorkProps, BaseOnboardingWorkOnyxProps, BaseOnboardingWorkProps};
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index fa939be4e63d..4fb85123df8a 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -1,6 +1,6 @@
import {useRoute} from '@react-navigation/native';
import type {StackScreenProps} from '@react-navigation/stack';
-import React, {useEffect, useMemo} from 'react';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
@@ -29,6 +29,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as Report from '@userActions/Report';
+import ConfirmModal from '@src/components/ConfirmModal';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -49,6 +50,7 @@ type ReportDetailsPageMenuItem = {
action: () => void;
brickRoadIndicator?: ValueOf;
subtitle?: number;
+ shouldShowRightIcon?: boolean;
};
type ReportDetailsPageOnyxProps = {
@@ -65,10 +67,12 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
const {isOffline} = useNetwork();
const styles = useThemeStyles();
const route = useRoute();
+ const [isLastMemberLeavingGroupModalVisible, setIsLastMemberLeavingGroupModalVisible] = useState(false);
const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? ''}`], [policies, report?.policyID]);
const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy ?? null), [policy]);
const isPolicyEmployee = useMemo(() => PolicyUtils.isPolicyEmployee(report?.policyID ?? '', policies), [report?.policyID, policies]);
const shouldUseFullTitle = useMemo(() => ReportUtils.shouldUseFullTitleToDisplay(report), [report]);
+ const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report);
const isChatRoom = useMemo(() => ReportUtils.isChatRoom(report), [report]);
const isUserCreatedPolicyRoom = useMemo(() => ReportUtils.isUserCreatedPolicyRoom(report), [report]);
const isDefaultRoom = useMemo(() => ReportUtils.isDefaultRoom(report), [report]);
@@ -78,6 +82,8 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
const isInvoiceReport = useMemo(() => ReportUtils.isInvoiceReport(report), [report]);
const canEditReportDescription = useMemo(() => ReportUtils.canEditReportDescription(report, policy), [report, policy]);
const shouldShowReportDescription = isChatRoom && (canEditReportDescription || report.description !== '');
+ const canLeaveRoom = ReportUtils.canLeaveRoom(report, isPolicyEmployee);
+ const canLeavePolicyExpenseChat = ReportUtils.canLeavePolicyExpenseChat(report, policy ?? {});
// eslint-disable-next-line react-hooks/exhaustive-deps -- policy is a dependency because `getChatRoomSubtitle` calls `getPolicyName` which in turn retrieves the value from the `policy` value stored in Onyx
const chatRoomSubtitle = useMemo(() => ReportUtils.getChatRoomSubtitle(report), [report, policy]);
@@ -98,10 +104,11 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
return !pendingMember || pendingMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? accountID : [];
});
- const isGroupDMChat = useMemo(() => ReportUtils.isDM(report) && participants.length > 1, [report, participants.length]);
const isPrivateNotesFetchTriggered = report?.isLoadingPrivateNotes !== undefined;
const isSelfDM = useMemo(() => ReportUtils.isSelfDM(report), [report]);
+ const canLeave =
+ !isSelfDM && (isChatThread || isUserCreatedPolicyRoom || canLeaveRoom || canLeavePolicyExpenseChat) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
useEffect(() => {
// Do not fetch private notes if isLoadingPrivateNotes is already defined, or if the network is offline, or if the report is a self DM.
@@ -112,6 +119,15 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
Report.getReportPrivateNote(report?.reportID ?? '');
}, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]);
+ const leaveChat = useCallback(() => {
+ if (isChatRoom) {
+ const isWorkspaceMemberLeavingWorkspaceRoom = (report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED || isPolicyExpenseChat) && isPolicyEmployee;
+ Report.leaveRoom(report.reportID, isWorkspaceMemberLeavingWorkspaceRoom);
+ return;
+ }
+ Report.leaveGroupChat(report.reportID);
+ }, [isChatRoom, isPolicyEmployee, isPolicyExpenseChat, report.reportID, report.visibility]);
+
const menuItems: ReportDetailsPageMenuItem[] = useMemo(() => {
const items: ReportDetailsPageMenuItem[] = [];
@@ -119,16 +135,6 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
return [];
}
- if (!isGroupDMChat) {
- items.push({
- key: CONST.REPORT_DETAILS_MENU_ITEM.SHARE_CODE,
- translationKey: 'common.shareCode',
- icon: Expensicons.QrCode,
- isAnonymousAction: true,
- action: () => Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.getRoute(report?.reportID ?? '')),
- });
- }
-
if (isArchivedRoom) {
return items;
}
@@ -195,10 +201,27 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
});
}
+ if (isGroupChat || (isChatRoom && canLeave)) {
+ items.push({
+ key: CONST.REPORT_DETAILS_MENU_ITEM.LEAVE_ROOM,
+ translationKey: 'common.leave',
+ icon: Expensicons.Exit,
+ isAnonymousAction: true,
+ shouldShowRightIcon: false,
+ action: () => {
+ if (Object.keys(report?.participants ?? {}).length === 1 && isGroupChat) {
+ setIsLastMemberLeavingGroupModalVisible(true);
+ return;
+ }
+
+ leaveChat();
+ },
+ });
+ }
+
return items;
}, [
isSelfDM,
- isGroupDMChat,
isArchivedRoom,
isGroupChat,
isDefaultRoom,
@@ -208,9 +231,12 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
participants.length,
report,
isMoneyRequestReport,
- isInvoiceReport,
+ isChatRoom,
+ canLeave,
activeChatMembers.length,
session,
+ leaveChat,
+ isInvoiceReport,
]);
const displayNamesWithTooltips = useMemo(() => {
@@ -319,7 +345,10 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
{shouldShowReportDescription && (
-
+
)}
- {isGroupChat && }
+ {(isGroupChat || isChatRoom) && }
{menuItems.map((item) => {
const brickRoadIndicator =
ReportUtils.hasReportNameError(report) && item.key === CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined;
@@ -343,12 +372,25 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
icon={item.icon}
onPress={item.action}
isAnonymousAction={item.isAnonymousAction}
- shouldShowRightIcon
+ shouldShowRightIcon={item.shouldShowRightIcon ?? true}
brickRoadIndicator={brickRoadIndicator ?? item.brickRoadIndicator}
/>
);
})}
+ {
+ setIsLastMemberLeavingGroupModalVisible(false);
+ Report.leaveGroupChat(report.reportID);
+ }}
+ onCancel={() => setIsLastMemberLeavingGroupModalVisible(false)}
+ prompt={translate('groupChat.lastMemberWarning')}
+ confirmText={translate('common.leave')}
+ cancelText={translate('common.cancel')}
+ />
);
diff --git a/src/pages/Search/SearchFilters.tsx b/src/pages/Search/SearchFilters.tsx
index 0ce2f958043c..06611db61824 100644
--- a/src/pages/Search/SearchFilters.tsx
+++ b/src/pages/Search/SearchFilters.tsx
@@ -6,12 +6,12 @@ import useSingleExecution from '@hooks/useSingleExecution';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import Navigation from '@libs/Navigation/Navigation';
+import variables from '@styles/variables';
import * as Expensicons from '@src/components/Icon/Expensicons';
import CONST from '@src/CONST';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import type IconAsset from '@src/types/utils/IconAsset';
-import SearchFiltersNarrow from './SearchFiltersNarrow';
type SearchFiltersProps = {
query: string;
@@ -19,6 +19,7 @@ type SearchFiltersProps = {
type SearchMenuFilterItem = {
title: string;
+ query: string;
icon: IconAsset;
route: Route;
};
@@ -31,25 +32,23 @@ function SearchFilters({query}: SearchFiltersProps) {
const filterItems: SearchMenuFilterItem[] = [
{
- title: translate('common.all'),
- icon: Expensicons.All,
+ title: translate('common.expenses'),
+ query: CONST.TAB_SEARCH.ALL,
+ icon: Expensicons.Receipt,
route: ROUTES.SEARCH.getRoute(CONST.TAB_SEARCH.ALL),
},
];
+ const activeItemIndex = filterItems.findIndex((item) => item.query === query);
+ // We're not showing the filters on mobile yet since there's only one search option.
+ // We'll introduce the filters as part of https://github.com/Expensify/App/issues/39878
if (isSmallScreenWidth) {
- return (
-
- );
+ return;
}
return (
- {filterItems.map((item) => {
- const isActive = item.title.toLowerCase() === query;
+ {filterItems.map((item, index) => {
const onPress = singleExecution(() => Navigation.navigate(item.route));
return (
@@ -59,8 +58,10 @@ function SearchFilters({query}: SearchFiltersProps) {
interactive
title={item.title}
icon={item.icon}
+ iconWidth={variables.iconSizeLarge}
+ iconHeight={variables.iconSizeLarge}
wrapperStyle={styles.sectionMenuItem}
- focused={isActive}
+ focused={index === activeItemIndex}
hoverAndPressStyle={styles.hoveredComponentBG}
onPress={onPress}
isPaneMenu
diff --git a/src/pages/Search/SearchFiltersNarrow.tsx b/src/pages/Search/SearchFiltersNarrow.tsx
index 01e750a9a2ce..e890432df7f1 100644
--- a/src/pages/Search/SearchFiltersNarrow.tsx
+++ b/src/pages/Search/SearchFiltersNarrow.tsx
@@ -14,10 +14,10 @@ import type {SearchMenuFilterItem} from './SearchFilters';
type SearchFiltersNarrowProps = {
filterItems: SearchMenuFilterItem[];
- activeItemLabel: string;
+ activeItemIndex: number;
};
-function SearchFiltersNarrow({filterItems, activeItemLabel}: SearchFiltersNarrowProps) {
+function SearchFiltersNarrow({filterItems, activeItemIndex}: SearchFiltersNarrowProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {singleExecution} = useSingleExecution();
@@ -29,7 +29,6 @@ function SearchFiltersNarrow({filterItems, activeItemLabel}: SearchFiltersNarrow
const openMenu = () => setIsPopoverVisible(true);
const closeMenu = () => setIsPopoverVisible(false);
- const activeItemIndex = filterItems.findIndex((item) => item.title.toLowerCase() === activeItemLabel);
const popoverMenuItems = filterItems.map((item, index) => ({
text: item.title,
onSelected: singleExecution(() => Navigation.navigate(item.route)),
@@ -47,14 +46,14 @@ function SearchFiltersNarrow({filterItems, activeItemLabel}: SearchFiltersNarrow
accessible
accessibilityLabel={popoverMenuItems[activeItemIndex]?.text ?? ''}
ref={buttonRef}
- style={[styles.tabSelectorButton]}
+ style={[styles.tabSelectorButton, styles.ph5]}
onPress={openMenu}
>
{({hovered}) => (
{popoverMenuItems[activeItemIndex]?.text}
diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx
index 777ccaad4e9d..499b09fd4eb9 100644
--- a/src/pages/Search/SearchPage.tsx
+++ b/src/pages/Search/SearchPage.tsx
@@ -5,6 +5,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Illustrations from '@components/Icon/Illustrations';
import ScreenWrapper from '@components/ScreenWrapper';
import Search from '@components/Search';
+import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
import type {CentralPaneNavigatorParamList} from '@libs/Navigation/types';
import CONST from '@src/CONST';
@@ -15,6 +16,7 @@ import type {SearchQuery} from '@src/types/onyx/SearchResults';
type SearchPageProps = StackScreenProps;
function SearchPage({route}: SearchPageProps) {
+ const {translate} = useLocalize();
const currentQuery = route?.params && 'query' in route.params ? route?.params?.query : '';
const query = currentQuery as SearchQuery;
const isValidQuery = Object.values(CONST.TAB_SEARCH).includes(query);
@@ -30,7 +32,7 @@ function SearchPage({route}: SearchPageProps) {
shouldShowLink={false}
>
diff --git a/src/pages/Travel/ManageTrips.tsx b/src/pages/Travel/ManageTrips.tsx
new file mode 100644
index 000000000000..401f06277b5e
--- /dev/null
+++ b/src/pages/Travel/ManageTrips.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import {View} from 'react-native';
+import type {FeatureListItem} from '@components/FeatureList';
+import FeatureList from '@components/FeatureList';
+import * as Illustrations from '@components/Icon/Illustrations';
+import LottieAnimations from '@components/LottieAnimations';
+import ScrollView from '@components/ScrollView';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import Navigation from '@libs/Navigation/Navigation';
+import colors from '@styles/theme/colors';
+import ROUTES from '@src/ROUTES';
+import getTripIllustrationStyle from './getTripIllustrationStyle';
+
+const tripsFeatures: FeatureListItem[] = [
+ {
+ icon: Illustrations.PiggyBank,
+ translationKey: 'travel.features.saveMoney',
+ },
+ {
+ icon: Illustrations.Alert,
+ translationKey: 'travel.features.alerts',
+ },
+];
+
+function ManageTrips() {
+ const styles = useThemeStyles();
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const {translate} = useLocalize();
+ const illustrationStyle = getTripIllustrationStyle();
+
+ return (
+
+
+ {
+ Navigation.navigate(ROUTES.TRAVEL_TCS);
+ }}
+ illustration={LottieAnimations.Plane}
+ illustrationStyle={illustrationStyle}
+ illustrationBackgroundColor={colors.blue600}
+ titleStyles={styles.textHeadlineH1}
+ contentPaddingOnLargeScreens={styles.p5}
+ />
+
+
+ );
+}
+
+ManageTrips.displayName = 'ManageTrips';
+
+export default ManageTrips;
diff --git a/src/pages/Travel/MyTripsPage.tsx b/src/pages/Travel/MyTripsPage.tsx
new file mode 100644
index 000000000000..be29e8dc8c12
--- /dev/null
+++ b/src/pages/Travel/MyTripsPage.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import ManageTrips from './ManageTrips';
+
+function MyTripsPage() {
+ const {translate} = useLocalize();
+ const {canUseSpotnanaTravel} = usePermissions();
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+MyTripsPage.displayName = 'MyTripsPage';
+
+export default MyTripsPage;
diff --git a/src/pages/Travel/TravelTerms.tsx b/src/pages/Travel/TravelTerms.tsx
new file mode 100644
index 000000000000..468ca9b8082a
--- /dev/null
+++ b/src/pages/Travel/TravelTerms.tsx
@@ -0,0 +1,111 @@
+import React, {useCallback, useEffect, useState} from 'react';
+import {View} from 'react-native';
+import {ScrollView} from 'react-native-gesture-handler';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import CheckboxWithLabel from '@components/CheckboxWithLabel';
+import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import SafeAreaConsumer from '@components/SafeAreaConsumer';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import TextLink from '@components/TextLink';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import * as Travel from '@userActions/Travel';
+import CONST from '@src/CONST';
+
+function TravelTerms() {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {canUseSpotnanaTravel} = usePermissions();
+ const [hasAcceptedTravelTerms, setHasAcceptedTravelTerms] = useState(false);
+ const [error, setError] = useState(false);
+
+ const errorMessage = error ? 'travel.termsAndConditions.error' : '';
+
+ const toggleTravelTerms = () => {
+ setHasAcceptedTravelTerms(!hasAcceptedTravelTerms);
+ };
+
+ useEffect(() => {
+ if (!hasAcceptedTravelTerms) {
+ return;
+ }
+
+ setError(false);
+ }, [hasAcceptedTravelTerms]);
+
+ const AgreeToTheLabel = useCallback(
+ () => (
+
+ {`${translate('travel.termsAndConditions.agree')}`}
+ {`${translate('travel.termsAndConditions.travelTermsAndConditions')}`}
+
+ ),
+ [translate],
+ );
+
+ // Add beta support for FullPageNotFound that is universal across travel pages
+ return (
+
+
+ Navigation.goBack()}
+ />
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+ {`${translate('travel.termsAndConditions.title')}`}
+
+ {`${translate('travel.termsAndConditions.subtitle')}`}
+ {`${translate('travel.termsAndConditions.termsconditions')}.`}
+
+
+ {`${translate('travel.termsAndConditions.helpDocIntro')}`}
+ {`${translate('travel.termsAndConditions.helpDoc')} `}
+ {`${translate('travel.termsAndConditions.helpDocOutro')}`}
+
+
+
+
+ {
+ if (!hasAcceptedTravelTerms) {
+ setError(true);
+ return;
+ }
+
+ Travel.acceptSpotnanaTerms();
+ setError(false);
+ Navigation.resetToHome();
+ }}
+ message={errorMessage}
+ isAlertVisible={error || Boolean(errorMessage)}
+ containerStyles={[styles.mh0, styles.mt5]}
+ />
+
+ )}
+
+
+
+ );
+}
+
+TravelTerms.displayName = 'TravelMenu';
+
+export default TravelTerms;
diff --git a/src/pages/Travel/getTripIllustrationStyle/index.native.ts b/src/pages/Travel/getTripIllustrationStyle/index.native.ts
new file mode 100644
index 000000000000..e5b0a1381d7e
--- /dev/null
+++ b/src/pages/Travel/getTripIllustrationStyle/index.native.ts
@@ -0,0 +1,8 @@
+import type {ViewStyle} from 'react-native';
+
+// Styling lottie animations for the ManageTrips component requires different margin values depending on the platform.
+export default function getTripIllustrationStyle(): ViewStyle {
+ return {
+ marginVertical: 20,
+ };
+}
diff --git a/src/pages/Travel/getTripIllustrationStyle/index.ts b/src/pages/Travel/getTripIllustrationStyle/index.ts
new file mode 100644
index 000000000000..a2a141022d73
--- /dev/null
+++ b/src/pages/Travel/getTripIllustrationStyle/index.ts
@@ -0,0 +1,9 @@
+import type {ViewStyle} from 'react-native';
+
+// Styling lottie animations for the ManageTrips component requires different margin values depending on the platform.
+export default function getTripIllustrationStyle(): ViewStyle {
+ return {
+ marginTop: 20,
+ marginBottom: -20,
+ };
+}
diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx
index d84d610a171a..b82137756a28 100644
--- a/src/pages/home/ReportScreen.tsx
+++ b/src/pages/home/ReportScreen.tsx
@@ -347,7 +347,7 @@ function ReportScreen({
}
const transactionThreadReportID = useMemo(
- () => ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, reportActions ?? [], isOffline),
+ () => ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, reportActions ?? [], false, isOffline),
[report.reportID, reportActions, isOffline],
);
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index 917031336fc8..5697807ca825 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -612,6 +612,8 @@ function ReportActionItem({
children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) {
children = ;
+ } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION) {
+ children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION) {
children = ;
} else {
diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx
index 7d95d3555502..fd41185242bb 100644
--- a/src/pages/home/report/ReportActionsList.tsx
+++ b/src/pages/home/report/ReportActionsList.tsx
@@ -200,7 +200,7 @@ function ReportActionsList({
);
const lastActionIndex = sortedVisibleReportActions[0]?.reportActionID;
const reportActionSize = useRef(sortedVisibleReportActions.length);
- const hasNewestReportAction = sortedReportActions?.[0].created === report.lastVisibleActionCreated;
+ const hasNewestReportAction = sortedVisibleReportActions?.[0]?.created === report.lastVisibleActionCreated;
const hasNewestReportActionRef = useRef(hasNewestReportAction);
hasNewestReportActionRef.current = hasNewestReportAction;
const previousLastIndex = useRef(lastActionIndex);
diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx
index cb904327e625..02008a464859 100755
--- a/src/pages/home/report/ReportActionsView.tsx
+++ b/src/pages/home/report/ReportActionsView.tsx
@@ -138,22 +138,18 @@ function ReportActionsView({
// Get a sorted array of reportActions for both the current report and the transaction thread report associated with this report (if there is one)
// so that we display transaction-level and report-level report actions in order in the one-transaction view
- const [combinedReportActions, parentReportActionForTransactionThread] = useMemo(() => {
- if (isEmptyObject(transactionThreadReportActions)) {
- return [allReportActions, null];
- }
-
- // Filter out the created action from the transaction thread report actions, since we already have the parent report's created action in `reportActions`
- const filteredTransactionThreadReportActions = transactionThreadReportActions?.filter((action) => action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED);
- const moneyRequestAction = allReportActions.find((action) => action.reportActionID === transactionThreadReport?.parentReportActionID);
+ const combinedReportActions = useMemo(
+ () => ReportActionsUtils.getCombinedReportActions(allReportActions, transactionThreadReportActions),
+ [allReportActions, transactionThreadReportActions],
+ );
- // Filter out the expense actions because we don't want to show any preview actions for one-transaction reports
- const filteredReportActions = [...allReportActions, ...filteredTransactionThreadReportActions].filter((action) => {
- const actionType = (action as OnyxTypes.OriginalMessageIOU).originalMessage?.type ?? '';
- return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE && actionType !== CONST.IOU.REPORT_ACTION_TYPE.TRACK && !ReportActionsUtils.isSentMoneyReportAction(action);
- });
- return [ReportActionsUtils.getSortedReportActions(filteredReportActions, true), moneyRequestAction ?? null];
- }, [allReportActions, transactionThreadReportActions, transactionThreadReport?.parentReportActionID]);
+ const parentReportActionForTransactionThread = useMemo(
+ () =>
+ isEmptyObject(transactionThreadReportActions)
+ ? null
+ : (allReportActions.find((action) => action.reportActionID === transactionThreadReport?.parentReportActionID) as OnyxEntry),
+ [allReportActions, transactionThreadReportActions, transactionThreadReport?.parentReportActionID],
+ );
const indexOfLinkedAction = useMemo(() => {
if (!reportActionID) {
@@ -322,13 +318,13 @@ function ReportActionsView({
}
if (!isEmptyObject(transactionThreadReport)) {
- // Get newer actions based on the newest reportAction for the current report
+ // Get older actions based on the oldest reportAction for the current report
const oldestActionCurrentReport = reportActionIDMap.findLast((item) => item.reportID === reportID);
- Report.getNewerActions(oldestActionCurrentReport?.reportID ?? '0', oldestActionCurrentReport?.reportActionID ?? '0');
+ Report.getOlderActions(oldestActionCurrentReport?.reportID ?? '0', oldestActionCurrentReport?.reportActionID ?? '0');
- // Get newer actions based on the newest reportAction for the transaction thread report
+ // Get older actions based on the oldest reportAction for the transaction thread report
const oldestActionTransactionThreadReport = reportActionIDMap.findLast((item) => item.reportID === transactionThreadReport.reportID);
- Report.getNewerActions(oldestActionTransactionThreadReport?.reportID ?? '0', oldestActionTransactionThreadReport?.reportActionID ?? '0');
+ Report.getOlderActions(oldestActionTransactionThreadReport?.reportID ?? '0', oldestActionTransactionThreadReport?.reportActionID ?? '0');
} else {
// Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments
Report.getOlderActions(reportID, oldestReportAction.reportActionID);
diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
index bc7b49f94c91..8ac3f7e0ca94 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
@@ -180,6 +180,7 @@ function FloatingActionButtonAndPopover(
const prevIsFocused = usePrevious(isFocused);
const {isOffline} = useNetwork();
+ const {canUseSpotnanaTravel} = usePermissions();
const canSendInvoice = useMemo(() => PolicyUtils.canSendInvoice(allPolicies as OnyxCollection), [allPolicies]);
const quickActionAvatars = useMemo(() => {
@@ -403,6 +404,15 @@ function FloatingActionButtonAndPopover(
text: translate('newTaskPage.assignTask'),
onSelected: () => interceptAnonymousUser(() => Task.clearOutTaskInfoAndNavigate()),
},
+ ...(canUseSpotnanaTravel
+ ? [
+ {
+ icon: Expensicons.Suitcase,
+ text: translate('travel.bookTravel'),
+ onSelected: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.TRAVEL_MY_TRIPS)),
+ },
+ ]
+ : []),
...(!isLoading && !Policy.hasActiveChatEnabledPolicies(allPolicies)
? [
{
diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksPreferredExporterConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksPreferredExporterConfigurationPage.tsx
index a3eae0e19f24..a55b293fc3a1 100644
--- a/src/pages/workspace/accounting/qbo/export/QuickbooksPreferredExporterConfigurationPage.tsx
+++ b/src/pages/workspace/accounting/qbo/export/QuickbooksPreferredExporterConfigurationPage.tsx
@@ -5,9 +5,11 @@ import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import type {ListItem} from '@components/SelectionList/types';
import Text from '@components/Text';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Connections from '@libs/actions/connections';
+import * as PolicyUtils from '@libs/PolicyUtils';
import {getAdminEmployees} from '@libs/PolicyUtils';
import Navigation from '@navigation/Navigation';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
@@ -25,22 +27,29 @@ function QuickbooksPreferredExporterConfigurationPage({policy}: WithPolicyConnec
const styles = useThemeStyles();
const {export: exportConfiguration} = policy?.connections?.quickbooksOnline?.config ?? {};
const exporters = getAdminEmployees(policy);
+ const {login: currentUserLogin} = useCurrentUserPersonalDetails();
const policyID = policy?.id ?? '';
const data: CardListItem[] = useMemo(
() =>
- exporters?.reduce((vendors, vendor) => {
- if (vendor.email) {
- vendors.push({
- value: vendor.email,
- text: vendor.email,
- keyForList: vendor.email,
- isSelected: exportConfiguration?.exporter === vendor.email,
- });
+ exporters?.reduce((options, exporter) => {
+ if (!exporter.email) {
+ return options;
}
- return vendors;
+
+ // Don't show guides if the current user is not a guide themselves or an Expensify employee
+ if (PolicyUtils.isExpensifyTeam(exporter.email) && !PolicyUtils.isExpensifyTeam(policy?.owner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
+ return options;
+ }
+ options.push({
+ value: exporter.email,
+ text: exporter.email,
+ keyForList: exporter.email,
+ isSelected: exportConfiguration?.exporter === exporter.email,
+ });
+ return options;
}, []),
- [exportConfiguration, exporters],
+ [exportConfiguration, exporters, currentUserLogin, policy?.owner],
);
const selectExporter = useCallback(
diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx
index 21741a417a26..33f3e82ed05f 100644
--- a/src/pages/workspace/categories/CategorySettingsPage.tsx
+++ b/src/pages/workspace/categories/CategorySettingsPage.tsx
@@ -1,7 +1,7 @@
import type {StackScreenProps} from '@react-navigation/stack';
import React, {useState} from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx, withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
@@ -39,6 +39,7 @@ function CategorySettingsPage({route, policyCategories}: CategorySettingsPagePro
const {translate} = useLocalize();
const {windowWidth} = useWindowDimensions();
const [deleteCategoryConfirmModalVisible, setDeleteCategoryConfirmModalVisible] = useState(false);
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`);
const policyCategory = policyCategories?.[route.params.categoryName];
@@ -60,13 +61,15 @@ function CategorySettingsPage({route, policyCategories}: CategorySettingsPagePro
Navigation.dismissModal();
};
- const threeDotsMenuItems = [
- {
+ const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0;
+ const threeDotsMenuItems = [];
+ if (!isThereAnyAccountingConnection) {
+ threeDotsMenuItems.push({
icon: Expensicons.Trashcan,
text: translate('workspace.categories.deleteCategory'),
onSelected: () => setDeleteCategoryConfirmModalVisible(true),
- },
- ];
+ });
+ }
return (
0}
title={route.params.categoryName}
threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)}
threeDotsMenuItems={threeDotsMenuItems}
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
index c81140314794..12606a9284fd 100644
--- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
+++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
@@ -146,14 +146,17 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) {
const getHeaderButtons = () => {
const options: Array>> = [];
+ const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0;
if (selectedCategoriesArray.length > 0) {
- options.push({
- icon: Expensicons.Trashcan,
- text: translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'),
- value: CONST.POLICY.CATEGORIES_BULK_ACTION_TYPES.DELETE,
- onSelected: () => setDeleteCategoriesConfirmModalVisible(true),
- });
+ if (!isThereAnyAccountingConnection) {
+ options.push({
+ icon: Expensicons.Trashcan,
+ text: translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'),
+ value: CONST.POLICY.CATEGORIES_BULK_ACTION_TYPES.DELETE,
+ onSelected: () => setDeleteCategoriesConfirmModalVisible(true),
+ });
+ }
const enabledCategories = selectedCategoriesArray.filter((categoryName) => policyCategories?.[categoryName]?.enabled);
if (enabledCategories.length > 0) {
diff --git a/src/pages/workspace/tags/TagSettingsPage.tsx b/src/pages/workspace/tags/TagSettingsPage.tsx
index ed88a1a9b636..54316efbc14b 100644
--- a/src/pages/workspace/tags/TagSettingsPage.tsx
+++ b/src/pages/workspace/tags/TagSettingsPage.tsx
@@ -1,7 +1,7 @@
import type {StackScreenProps} from '@react-navigation/stack';
import React, {useMemo} from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx, withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
@@ -39,6 +39,7 @@ function TagSettingsPage({route, policyTags}: TagSettingsPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const policyTag = useMemo(() => PolicyUtils.getTagList(policyTags, 0), [policyTags]);
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`);
const {windowWidth} = useWindowDimensions();
@@ -64,6 +65,16 @@ function TagSettingsPage({route, policyTags}: TagSettingsPageProps) {
Navigation.navigate(ROUTES.WORKSPACE_TAG_EDIT.getRoute(route.params.policyID, currentPolicyTag.name));
};
+ const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0;
+ const threeDotsMenuItems = [];
+ if (!isThereAnyAccountingConnection) {
+ threeDotsMenuItems.push({
+ icon: Trashcan,
+ text: translate('workspace.tags.deleteTag'),
+ onSelected: () => setIsDeleteTagModalOpen(true),
+ });
+ }
+
return (
0}
shouldSetModalVisibility={false}
threeDotsAnchorPosition={styles.threeDotsPopoverOffset(windowWidth)}
- threeDotsMenuItems={[
- {
- icon: Trashcan,
- text: translate('workspace.tags.deleteTag'),
- onSelected: () => setIsDeleteTagModalOpen(true),
- },
- ]}
+ threeDotsMenuItems={threeDotsMenuItems}
/>
{
const options: Array>> = [];
+ const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0;
if (selectedTagsArray.length > 0) {
- options.push({
- icon: Expensicons.Trashcan,
- text: translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTag' : 'workspace.tags.deleteTags'),
- value: CONST.POLICY.TAGS_BULK_ACTION_TYPES.DELETE,
- onSelected: () => setDeleteTagsConfirmModalVisible(true),
- });
+ if (!isThereAnyAccountingConnection) {
+ options.push({
+ icon: Expensicons.Trashcan,
+ text: translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTag' : 'workspace.tags.deleteTags'),
+ value: CONST.POLICY.TAGS_BULK_ACTION_TYPES.DELETE,
+ onSelected: () => setDeleteTagsConfirmModalVisible(true),
+ });
+ }
const enabledTags = selectedTagsArray.filter((tagName) => tagListKeyedByName?.[tagName]?.enabled);
if (enabledTags.length > 0) {
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 365c89ac4a18..1b1587d81bce 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -1868,6 +1868,11 @@ const styles = (theme: ThemeColors) =>
marginBottom: -20,
},
+ travelIllustrationStyle: {
+ marginTop: 16,
+ marginBottom: -16,
+ },
+
overlayStyles: (current: OverlayStylesParams, isModalOnTheLeft: boolean) =>
({
...positioning.pFixed,
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index d6f05f1f3b76..ca238c3ffd88 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1522,6 +1522,37 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
return isDragging ? styles.cursorGrabbing : styles.cursorZoomOut;
},
+ getSearchTableColumnStyles: (columnName: string): ViewStyle => {
+ let columnWidth;
+ switch (columnName) {
+ case CONST.SEARCH_TABLE_COLUMNS.DATE:
+ columnWidth = getWidthStyle(variables.w44);
+ break;
+ case CONST.SEARCH_TABLE_COLUMNS.MERCHANT:
+ columnWidth = styles.flex1;
+ break;
+ case CONST.SEARCH_TABLE_COLUMNS.FROM:
+ columnWidth = styles.flex1;
+ break;
+ case CONST.SEARCH_TABLE_COLUMNS.TO:
+ columnWidth = styles.flex1;
+ break;
+ case CONST.SEARCH_TABLE_COLUMNS.TOTAL:
+ columnWidth = {...getWidthStyle(variables.w96), ...styles.alignItemsEnd};
+ break;
+ case CONST.SEARCH_TABLE_COLUMNS.TYPE:
+ columnWidth = {...getWidthStyle(variables.w28), ...styles.alignItemsCenter};
+ break;
+ case CONST.SEARCH_TABLE_COLUMNS.ACTION:
+ columnWidth = getWidthStyle(variables.w80);
+ break;
+ default:
+ columnWidth = styles.flex1;
+ }
+
+ return columnWidth;
+ },
+
/**
* Returns container styles for showing the icons in MultipleAvatars/SubscriptAvatar
*/
diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts
index 371860a59efc..ec9aa1ce4c5c 100644
--- a/src/styles/utils/sizing.ts
+++ b/src/styles/utils/sizing.ts
@@ -82,6 +82,10 @@ export default {
maxWidth: 'auto',
},
+ mw50: {
+ maxWidth: '50%',
+ },
+
mw75: {
maxWidth: '75%',
},
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index ad0ffeecda40..7ab469af9533 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -211,7 +211,7 @@ export default {
hoverDimValue: 1,
pressDimValue: 0.8,
qrShareHorizontalPadding: 32,
- purposeMenuIconSize: 48,
+ menuIconSize: 48,
moneyRequestSkeletonHeight: 107,
@@ -230,4 +230,9 @@ export default {
mushroomTopHatHeight: 128,
bankButtonMargin: 23,
textInputAutoGrowMaxHeight: 115,
+
+ w28: 28,
+ w44: 44,
+ w80: 80,
+ w96: 96,
} as const;
diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts
index 67ff12c74150..c53d7ea816f8 100644
--- a/src/types/onyx/Account.ts
+++ b/src/types/onyx/Account.ts
@@ -2,6 +2,7 @@ import type {ValueOf} from 'type-fest';
import type CONST from '@src/CONST';
import type DismissedReferralBanners from './DismissedReferralBanners';
import type * as OnyxCommon from './OnyxCommon';
+import type {TravelSettings} from './TravelSettings';
type TwoFactorAuthStep = ValueOf | '';
@@ -59,6 +60,9 @@ type Account = {
codesAreCopied?: boolean;
twoFactorAuthStep?: TwoFactorAuthStep;
dismissedReferralBanners?: DismissedReferralBanners;
+
+ /** Object containing all account information necessary to connect with Spontana */
+ travelSettings?: TravelSettings;
};
export default Account;
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index a746053be64e..3cc9cb41cb20 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -67,6 +67,7 @@ type IOUMessage = {
type: ValueOf;
cancellationReason?: string;
paymentType?: PaymentMethodType;
+ deleted?: string;
/** Only exists when we are sending money */
IOUDetails?: IOUDetails;
};
@@ -311,6 +312,11 @@ type OriginalMessageMoved = {
};
};
+type OriginalMessageMergedWithCashTransaction = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION;
+ originalMessage: Record; // No data is sent with this action
+};
+
type OriginalMessageDismissedViolation = {
actionName: typeof CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION;
originalMessage: {
@@ -343,6 +349,7 @@ type OriginalMessage =
| OriginalMessageMoved
| OriginalMessageMarkedReimbursed
| OriginalMessageActionableTrackedExpenseWhisper
+ | OriginalMessageMergedWithCashTransaction
| OriginalMessageDismissedViolation;
export default OriginalMessage;
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 20ce48a8130d..3a322405c6e1 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -3,6 +3,7 @@ import type CONST from '@src/CONST';
import type {Country} from '@src/CONST';
import type * as OnyxTypes from '.';
import type * as OnyxCommon from './OnyxCommon';
+import type {WorkspaceTravelSettings} from './TravelSettings';
type Unit = 'mi' | 'km';
@@ -520,6 +521,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Indicates if the Policy ownership change is failed */
isChangeOwnerFailed?: boolean;
+
+ /** Object containing all policy information necessary to connect with Spontana */
+ travelSettings?: WorkspaceTravelSettings;
} & Partial,
'generalSettings' | 'addWorkspaceRoom' | keyof ACHAccount
>;
diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts
index de159a433f37..af23821d70e8 100644
--- a/src/types/onyx/SearchResults.ts
+++ b/src/types/onyx/SearchResults.ts
@@ -8,6 +8,19 @@ type SearchResultsInfo = {
hasMoreResults: boolean;
};
+type SearchPersonalDetails = {
+ accountID: number;
+ avatar: string;
+ displayName?: string;
+ login?: string;
+};
+
+type SearchPolicyDetails = {
+ id: string;
+ avatarURL: string;
+ name: string;
+};
+
type SearchTransaction = {
transactionID: string;
parentTransactionID?: string;
@@ -20,6 +33,8 @@ type SearchTransaction = {
description: string;
accountID: number;
managerID: number;
+ from: SearchPersonalDetails | SearchPolicyDetails;
+ to: SearchPersonalDetails | SearchPolicyDetails;
amount: number;
modifiedAmount?: number;
category?: string;
@@ -29,7 +44,10 @@ type SearchTransaction = {
hasViolation: boolean;
taxAmount?: number;
reportID: string;
- transactionThreadReportID: string; // Not present in live transactions_
+ reportType: string;
+ policyID: string;
+ transactionThreadReportID: string;
+ shouldShowMerchant: boolean;
action: string;
};
@@ -39,9 +57,9 @@ type SearchQuery = ValueOf;
type SearchResults = {
search: SearchResultsInfo;
- data: Record;
+ data: Record> & Record;
};
export default SearchResults;
-export type {SearchQuery, SearchTransaction, SearchTransactionType};
+export type {SearchQuery, SearchTransaction, SearchTransactionType, SearchPersonalDetails, SearchPolicyDetails};
diff --git a/src/types/onyx/TravelSettings.ts b/src/types/onyx/TravelSettings.ts
new file mode 100644
index 000000000000..e653c3378572
--- /dev/null
+++ b/src/types/onyx/TravelSettings.ts
@@ -0,0 +1,17 @@
+type TravelSettings = {
+ /** UUIDs that spotnana provides us with when we provision users in their system, and the spotnanaCompanyIDs as the values */
+ accountIDs: Record;
+
+ /** Whether the user has completed the travel terms and conditions checkbox */
+ hasAcceptedTerms: boolean;
+};
+
+type WorkspaceTravelSettings = {
+ /** The UUID that spotnana provides us when we create a “company” in their system */
+ spotnanaCompanyID: string;
+
+ /** The UUID that spotnana provides us when we provision the workspace as an “entity” in their system */
+ spotnanaEntityID: boolean;
+};
+
+export type {TravelSettings, WorkspaceTravelSettings};
diff --git a/tests/actions/PolicyTagTest.ts b/tests/actions/PolicyTagTest.ts
new file mode 100644
index 000000000000..ff4439c392fa
--- /dev/null
+++ b/tests/actions/PolicyTagTest.ts
@@ -0,0 +1,769 @@
+import Onyx from 'react-native-onyx';
+import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager';
+import * as Policy from '@libs/actions/Policy';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PolicyTags} from '@src/types/onyx';
+import createRandomPolicy from '../utils/collections/policies';
+import createRandomPolicyTags from '../utils/collections/policyTags';
+import * as TestHelper from '../utils/TestHelper';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+OnyxUpdateManager();
+describe('actions/Policy', () => {
+ beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
+ });
+
+ beforeEach(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ global.fetch = TestHelper.getGlobalFetchMock();
+ return Onyx.clear().then(waitForBatchedUpdates);
+ });
+
+ describe('SetPolicyRequiresTag', () => {
+ it('enable require tag', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.requiresTag = false;
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Policy.setPolicyRequiresTag(fakePolicy.id, true);
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+
+ // RequiresTag is enabled and pending
+ expect(policy?.requiresTag).toBeTruthy();
+ expect(policy?.pendingFields?.requiresTag).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.pendingFields?.requiresTag).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('disable require tag', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.requiresTag = true;
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Policy.setPolicyRequiresTag(fakePolicy.id, false);
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+
+ // RequiresTag is disabled and pending
+ expect(policy?.requiresTag).toBeFalsy();
+ expect(policy?.pendingFields?.requiresTag).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.pendingFields?.requiresTag).toBeFalsy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('reset require tag when api returns an error', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.requiresTag = true;
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+ Policy.setPolicyRequiresTag(fakePolicy.id, false);
+ return waitForBatchedUpdates();
+ })
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policy) => {
+ Onyx.disconnect(connectionID);
+ expect(policy?.pendingFields?.requiresTag).toBeFalsy();
+ expect(policy?.errors).toBeTruthy();
+ expect(policy?.requiresTag).toBeTruthy();
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+ });
+
+ describe('RenamePolicyTaglist', () => {
+ it('rename policy tag list', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.areTagsEnabled = true;
+
+ const oldTagListName = 'Old tag list name';
+ const newTagListName = 'New tag list name';
+ const fakePolicyTags = createRandomPolicyTags(oldTagListName);
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, fakePolicyTags);
+ })
+ .then(() => {
+ Policy.renamePolicyTaglist(
+ fakePolicy.id,
+ {
+ oldName: oldTagListName,
+ newName: newTagListName,
+ },
+ fakePolicyTags,
+ );
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ // Tag list name is updated and pending
+ expect(Object.keys(policyTags?.[oldTagListName] ?? {}).length).toBe(0);
+ expect(policyTags?.[newTagListName]?.name).toBe(newTagListName);
+ expect(policyTags?.[newTagListName]?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ expect(policyTags?.[newTagListName]?.pendingAction).toBeFalsy();
+ expect(Object.keys(policyTags?.[oldTagListName] ?? {}).length).toBe(0);
+
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('reset the policy tag list name when api returns error', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.areTagsEnabled = true;
+
+ const oldTagListName = 'Old tag list name';
+ const newTagListName = 'New tag list name';
+ const fakePolicyTags = createRandomPolicyTags(oldTagListName);
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, fakePolicyTags);
+ })
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+
+ Policy.renamePolicyTaglist(
+ fakePolicy.id,
+ {
+ oldName: oldTagListName,
+ newName: newTagListName,
+ },
+ fakePolicyTags,
+ );
+ return waitForBatchedUpdates();
+ })
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ expect(policyTags?.[newTagListName]).toBeFalsy();
+ expect(policyTags?.[oldTagListName]).toBeTruthy();
+ expect(policyTags?.errors).toBeTruthy();
+
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+ });
+
+ describe('CreatePolicyTag', () => {
+ it('create new policy tag', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.areTagsEnabled = true;
+
+ const tagListName = 'Fake tag';
+ const newTagName = 'new tag';
+ const fakePolicyTags = createRandomPolicyTags(tagListName);
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, fakePolicyTags);
+ })
+ .then(() => {
+ Policy.createPolicyTag(fakePolicy.id, newTagName);
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ const newTag = policyTags?.[tagListName]?.tags?.[newTagName];
+ expect(newTag?.name).toBe(newTagName);
+ expect(newTag?.enabled).toBe(true);
+ expect(newTag?.errors).toBeFalsy();
+ expect(newTag?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ const newTag = policyTags?.[tagListName]?.tags?.[newTagName];
+ expect(newTag?.errors).toBeFalsy();
+ expect(newTag?.pendingAction).toBeFalsy();
+
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('reset new policy tag when api returns error', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.areTagsEnabled = true;
+
+ const tagListName = 'Fake tag';
+ const newTagName = 'new tag';
+ const fakePolicyTags = createRandomPolicyTags(tagListName);
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, fakePolicyTags);
+ })
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+
+ Policy.createPolicyTag(fakePolicy.id, newTagName);
+ return waitForBatchedUpdates();
+ })
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ const newTag = policyTags?.[tagListName]?.tags?.[newTagName];
+ expect(newTag?.errors).toBeTruthy();
+
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+ });
+
+ describe('SetPolicyTagsEnabled', () => {
+ it('set policy tag enable', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.areTagsEnabled = true;
+
+ const tagListName = 'Fake tag';
+ const fakePolicyTags = createRandomPolicyTags(tagListName, 2);
+ const tagsToUpdate = Object.keys(fakePolicyTags?.[tagListName]?.tags ?? {}).reduce((acc, key) => {
+ acc[key] = {
+ name: fakePolicyTags?.[tagListName]?.tags[key].name,
+ enabled: false,
+ };
+ return acc;
+ }, {});
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, fakePolicyTags);
+ })
+ .then(() => {
+ Policy.setWorkspaceTagEnabled(fakePolicy.id, tagsToUpdate);
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ Object.keys(tagsToUpdate).forEach((key) => {
+ const updatedTag = policyTags?.[tagListName]?.tags[key];
+ expect(updatedTag?.enabled).toBeFalsy();
+ expect(updatedTag?.errors).toBeFalsy();
+ expect(updatedTag?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(updatedTag?.pendingFields?.enabled).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ });
+
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ Object.keys(tagsToUpdate).forEach((key) => {
+ const updatedTag = policyTags?.[tagListName]?.tags[key];
+ expect(updatedTag?.errors).toBeFalsy();
+ expect(updatedTag?.pendingAction).toBeFalsy();
+ expect(updatedTag?.pendingFields?.enabled).toBeFalsy();
+ });
+
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('reset policy tag enable when api returns error', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.areTagsEnabled = true;
+
+ const tagListName = 'Fake tag';
+ const fakePolicyTags = createRandomPolicyTags(tagListName, 2);
+ const tagsToUpdate = Object.keys(fakePolicyTags?.[tagListName]?.tags ?? {}).reduce((acc, key) => {
+ acc[key] = {
+ name: fakePolicyTags?.[tagListName]?.tags[key].name,
+ enabled: false,
+ };
+ return acc;
+ }, {});
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, fakePolicyTags);
+ })
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+
+ Policy.setWorkspaceTagEnabled(fakePolicy.id, tagsToUpdate);
+ return waitForBatchedUpdates();
+ })
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ Object.keys(tagsToUpdate).forEach((key) => {
+ const updatedTag = policyTags?.[tagListName]?.tags[key];
+ expect(updatedTag?.errors).toBeTruthy();
+ expect(updatedTag?.pendingAction).toBeFalsy();
+ expect(updatedTag?.pendingFields?.enabled).toBeFalsy();
+ });
+
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+ });
+
+ describe('RenamePolicyTag', () => {
+ it('rename policy tag', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.areTagsEnabled = true;
+
+ const tagListName = 'Fake tag';
+ const fakePolicyTags = createRandomPolicyTags(tagListName, 2);
+ const oldTagName = Object.keys(fakePolicyTags?.[tagListName]?.tags)[0];
+ const newTagName = 'New tag';
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, fakePolicyTags);
+ })
+ .then(() => {
+ Policy.renamePolicyTag(fakePolicy.id, {
+ oldName: oldTagName,
+ newName: newTagName,
+ });
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ const tags = policyTags?.[tagListName]?.tags;
+ expect(tags?.[oldTagName]).toBeFalsy();
+ expect(tags?.[newTagName]?.name).toBe(newTagName);
+ expect(tags?.[newTagName]?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ expect(tags?.[newTagName]?.pendingFields?.name).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ const tags = policyTags?.[tagListName]?.tags;
+ expect(tags?.[newTagName]?.pendingAction).toBeFalsy();
+ expect(tags?.[newTagName]?.pendingFields?.name).toBeFalsy();
+
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('reset policy tag name when api returns error', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.areTagsEnabled = true;
+
+ const tagListName = 'Fake tag';
+ const fakePolicyTags = createRandomPolicyTags(tagListName, 2);
+ const oldTagName = Object.keys(fakePolicyTags?.[tagListName]?.tags)[0];
+ const newTagName = 'New tag';
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, fakePolicyTags);
+ })
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+
+ Policy.renamePolicyTag(fakePolicy.id, {
+ oldName: oldTagName,
+ newName: newTagName,
+ });
+ return waitForBatchedUpdates();
+ })
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ const tags = policyTags?.[tagListName]?.tags;
+ expect(tags?.[newTagName]).toBeFalsy();
+ expect(tags?.[oldTagName]?.errors).toBeTruthy();
+
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+ });
+
+ describe('DeletePolicyTags', () => {
+ it('delete policy tag', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.areTagsEnabled = true;
+
+ const tagListName = 'Fake tag';
+ const fakePolicyTags = createRandomPolicyTags(tagListName, 2);
+ const tagsToDelete = Object.keys(fakePolicyTags?.[tagListName]?.tags ?? {});
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, fakePolicyTags);
+ })
+ .then(() => {
+ Policy.deletePolicyTags(fakePolicy.id, tagsToDelete);
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ tagsToDelete.forEach((tagName) => {
+ expect(policyTags?.[tagListName]?.tags[tagName]?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
+ });
+
+ resolve();
+ },
+ });
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ tagsToDelete.forEach((tagName) => {
+ expect(policyTags?.[tagListName]?.tags[tagName]).toBeFalsy();
+ });
+
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+
+ it('reset the deleted policy tag when api returns error', () => {
+ const fakePolicy = createRandomPolicy(0);
+ fakePolicy.areTagsEnabled = true;
+
+ const tagListName = 'Fake tag';
+ const fakePolicyTags = createRandomPolicyTags(tagListName, 2);
+ const tagsToDelete = Object.keys(fakePolicyTags?.[tagListName]?.tags ?? {});
+
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.pause();
+
+ return (
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy)
+ .then(() => {
+ Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, fakePolicyTags);
+ })
+ .then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ fetch.fail();
+
+ Policy.deletePolicyTags(fakePolicy.id, tagsToDelete);
+ return waitForBatchedUpdates();
+ })
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(fetch.resume)
+ .then(waitForBatchedUpdates)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`,
+ waitForCollectionCallback: false,
+ callback: (policyTags) => {
+ Onyx.disconnect(connectionID);
+
+ tagsToDelete.forEach((tagName) => {
+ expect(policyTags?.[tagListName]?.tags[tagName].pendingAction).toBeFalsy();
+ expect(policyTags?.[tagListName]?.tags[tagName].errors).toBeTruthy();
+ });
+
+ resolve();
+ },
+ });
+ }),
+ )
+ );
+ });
+ });
+});
diff --git a/tests/unit/ValidationUtilsTest.ts b/tests/unit/ValidationUtilsTest.ts
index a14d71136b22..fc5dacee2cf2 100644
--- a/tests/unit/ValidationUtilsTest.ts
+++ b/tests/unit/ValidationUtilsTest.ts
@@ -241,8 +241,8 @@ describe('ValidationUtils', () => {
expect(ValidationUtils.isValidRoomName('#')).toBe(false);
});
- test('room name with 81 characters', () => {
- expect(ValidationUtils.isValidRoomName('#123456789012345678901234567890123456789012345678901234567890123456789012345678901')).toBe(false);
+ test('room name with 101 characters', () => {
+ expect(ValidationUtils.isValidRoomName('#12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901')).toBe(false);
});
test('room name with lowercase letters, numbers, and dashes', () => {
diff --git a/tests/utils/collections/policyTags.ts b/tests/utils/collections/policyTags.ts
new file mode 100644
index 000000000000..ed5bbd7efda4
--- /dev/null
+++ b/tests/utils/collections/policyTags.ts
@@ -0,0 +1,22 @@
+import {randWord} from '@ngneat/falso';
+import type {PolicyTagList, PolicyTags} from '@src/types/onyx';
+
+export default function createRandomPolicyTags(tagListName: string, numberOfTags = 0): PolicyTagList {
+ const tags: PolicyTags = {};
+ for (let i = 0; i < numberOfTags; i++) {
+ const tagName = randWord();
+ tags[tagName] = {
+ name: tagName,
+ enabled: true,
+ };
+ }
+
+ return {
+ [tagListName]: {
+ name: tagListName,
+ orderWeight: 0,
+ required: false,
+ tags,
+ },
+ };
+}
diff --git a/tests/utils/collections/userAccount.ts b/tests/utils/collections/userAccount.ts
index 9e7c33a228d5..ebaa0a132169 100644
--- a/tests/utils/collections/userAccount.ts
+++ b/tests/utils/collections/userAccount.ts
@@ -7,6 +7,10 @@ function getValidAccount(credentialLogin = ''): Account {
primaryLogin: credentialLogin,
isLoading: false,
requiresTwoFactorAuth: false,
+ travelSettings: {
+ accountIDs: {},
+ hasAcceptedTerms: false,
+ },
};
}