diff --git a/android/app/build.gradle b/android/app/build.gradle index 74830269ad45..9c5db608a846 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 1001045501 - versionName "1.4.55-1" + versionCode 1001045503 + versionName "1.4.55-3" } flavorDimensions "default" diff --git a/docs/articles/expensify-classic/expenses/Export-expenses.md b/docs/articles/expensify-classic/expenses/Export-expenses.md new file mode 100644 index 000000000000..14c1532f84b5 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Export-expenses.md @@ -0,0 +1,13 @@ +--- +title: Export expenses +description: Export expenses to a CSV +--- +
+ +1. Click the **Expenses** tab. +2. Select the expenses you want to export by checking the box to the left of each expense or selecting them all. +3. Click **Export To** in the right corner and select either: + - **Default CSV**: Use Expensify’s default template + - **Create new CSV export layout**: Create your own custom CSV template + +
diff --git a/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md b/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md new file mode 100644 index 000000000000..9037e58661d1 --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md @@ -0,0 +1,30 @@ +--- +title: Assign billing owner and payment account +description: Determine who will cover the cost of the workspace and link a payment method +--- +
+ +The person who creates a workspace will automatically be responsible for the billing for that workspace. However, the existing billing owner can transfer the workspace’s billing ownership to any Admin on the workspace. + +{% include info.html %} +There can only be one billing owner at a time. Assigning a new billing owner will automatically un-assign the existing billing owner. However, billing owners are also workspace admins by default, and the previous billing owner will remain a workspace admin unless manually updated. +{% include end-info.html %} + +# Assign a new billing owner + +To assign a new billing owner, **the person who will take over responsibility for the workspace billing must complete the following process**: + +1. Hover over Settings, then click **Workspaces**. +2. Click the desired workspace name. +3. Under Workspace Overview, click **Take Over Billing**. + +# Add or update payment account + +Once you take over billing for a workspace, you must add a payment method to your account. + +1. Hover over Settings, then click **Account**. +2. Click the **Payments** tab. +3. Scroll down to the Payment Details sections and click **Add Payment Card**. +4. Enter your credit or debit card information and click **Accept terms, add payment card, and pay $0.00** (the box will only show a balance if one is due). + +
diff --git a/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md b/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md new file mode 100644 index 000000000000..b0b016afbcbb --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md @@ -0,0 +1,24 @@ +--- +title: Create a group workspace +description: Create a workspace for your team's expense reports +--- +
+ +A workspace is the set of rules, settings, and spending limits for expense reports in your organization. This includes the unique expense categories and tags, budgets, currency and tax settings, etc. that all workspace members will use. A workspace also defines the approval workflow for your employees, as well as the accounting connection if using an accounting software integration. + +Here are a couple examples of when you’d want to create different workspaces: + +- You have employees with expense reports in different currencies. For example, you may have a workspace for employees who live in the US and submit their reports in USD and a workspace for employees who live in Canada and submit in CAD. +- You want to limit specific groups of people to their own set of expense coding options (categories/tags) then they can separate their employees by Sales, Marketing, Support, etc. + +To create a group workspace, + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click **New Workspace**. +4. Enter the workspace name and select a workspace type. + - **Collect**: Ideal for small groups who only need basic features like expense approvals, reimbursement, corporate card management, and integration options. + - **Control**: For groups that need a deeper level of control and configurations, like multi-stage approval workflows, corporate card management, integrations, and more. This is the most popular option. +5. Set up your workspace details including the workspace name, expense rules, categories, and more. + +
diff --git a/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md new file mode 100644 index 000000000000..c8be9a2728d5 --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md @@ -0,0 +1,20 @@ +--- +title: Set up your individual workspace +description: Capture your personal expenses +--- +
+ +All Expensify accounts come with an individual workspace where you can track your personal expenses. If you want to connect your personal expenses to an accounting or travel integration, you can create a group workspace—even if you will be the only person in the group. + +To set up your individual workspace, + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Individual** tab on the left. +3. Select the policy type that best fits your needs. +4. Set up your workspace details including the workspace name, expense rules, categories, and more. + +{% include info.html %} +You can create multiple group workspaces, but you can only create one individual workspace. +{% include end-info.html %} + +
diff --git a/docs/redirects.csv b/docs/redirects.csv index df4e2a45dce3..7539a2777d92 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -66,3 +66,4 @@ https://help.expensify.com/articles/expensify-classic/expensify-billing/Individu https://help.expensify.com/articles/expensify-classic/settings/Merge-Accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts https://help.expensify.com/articles/expensify-classic/settings/Preferences,https://help.expensify.com/expensify-classic/hubs/settings/account-settings https://help.expensify.com/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager,https://use.expensify.com/support +https://help.expensify.com/articles/expensify-classic/settings/Copilot,https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 22de50aee5a3..5e2ba1fcd614 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.55.1 + 1.4.55.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 61c240540779..69472200e46d 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.55.1 + 1.4.55.3 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index a30258647997..008ca16909b0 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 1.4.55 CFBundleVersion - 1.4.55.1 + 1.4.55.3 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 0d0b11fff3f1..4bff5eaf6eb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.55-1", + "version": "1.4.55-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.55-1", + "version": "1.4.55-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -51,7 +51,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4e020cfa13ffabde14313c92b341285aeb919f29", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.11.0", @@ -70,7 +70,7 @@ "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", "react-collapse": "^5.1.0", - "react-content-loader": "^6.1.0", + "react-content-loader": "^7.0.0", "react-dom": "18.1.0", "react-error-boundary": "^4.0.11", "react-map-gl": "^7.1.3", @@ -23087,9 +23087,9 @@ } }, "node_modules/classnames": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.4.0.tgz", - "integrity": "sha512-lWxiIlphgAhTLN657pwU/ofFxsUTOWc2CRIFeoV5st0MGRJHStUnWIUJgDHxjUO/F0mXzGufXIM4Lfu/8h+MpA==" + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.0.tgz", + "integrity": "sha512-FQuRlyKinxrb5gwJlfVASbSrDlikDJ07426TrfPsdGLvtochowmkbnSFdQGJ2aoXrSetq5KqGV9emvWpy+91xA==" }, "node_modules/clean-css": { "version": "5.3.2", @@ -27370,11 +27370,11 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae", - "integrity": "sha512-k/SmW3EBR+gxFkJP/59LJsmBKjnKR07XS30yk/GkQ0lIfyYkNmFJ0dWm/S/54ezFweezR7MDaQ3zGc45Mb/U5A==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#4e020cfa13ffabde14313c92b341285aeb919f29", + "integrity": "sha512-sx3cIYkmiydNaXRe4kJebPyEje8HfssUbsoB6uW8vvMLwFheCZfkmF9fRMBNLo8BQsfWIstT5TApEhwuWPjqZg==", "license": "MIT", "dependencies": { - "classnames": "2.4.0", + "classnames": "2.5.0", "clipboard": "2.0.11", "html-entities": "^2.4.0", "jquery": "3.6.0", @@ -27383,7 +27383,7 @@ "prop-types": "15.8.1", "react": "16.12.0", "react-dom": "16.12.0", - "semver": "^7.5.2", + "semver": "^7.6.0", "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", "ua-parser-js": "^1.0.37", "underscore": "1.13.6" @@ -38828,8 +38828,9 @@ } }, "node_modules/react-content-loader": { - "version": "6.2.0", - "license": "MIT", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/react-content-loader/-/react-content-loader-7.0.0.tgz", + "integrity": "sha512-xaBwpO7eiJyEc4ndym+g6wcruV9W2y3DKqbw4U48QFBsv0IeAVZO+aCUb8GptlDLWM8n5zi2HcFSGlj5r+53Tg==", "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index 5001e6f0dd1a..53eb229d7b85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.55-1", + "version": "1.4.55-3", "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.", @@ -102,7 +102,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4e020cfa13ffabde14313c92b341285aeb919f29", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.11.0", @@ -121,7 +121,7 @@ "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", "react-collapse": "^5.1.0", - "react-content-loader": "^6.1.0", + "react-content-loader": "^7.0.0", "react-dom": "18.1.0", "react-error-boundary": "^4.0.11", "react-map-gl": "^7.1.3", diff --git a/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch b/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch new file mode 100644 index 000000000000..e6a3822836f4 --- /dev/null +++ b/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch @@ -0,0 +1,43 @@ +diff --git a/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js b/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js +index 0aec2d6..a71aec2 100644 +--- a/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js ++++ b/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js +@@ -133,7 +133,7 @@ to return true:wantsResponderID| | + + import createResponderEvent from './createResponderEvent'; + import { isCancelish, isEndish, isMoveish, isScroll, isSelectionChange, isStartish } from './ResponderEventTypes'; +-import { getLowestCommonAncestor, getResponderPaths, hasTargetTouches, hasValidSelection, isPrimaryPointerDown, setResponderId } from './utils'; ++import { getLowestCommonAncestor, getResponderPaths, hasTargetTouches, hasValidSelection, isPrimaryOrSecondaryPointerDown, setResponderId } from './utils'; + import { ResponderTouchHistoryStore } from './ResponderTouchHistoryStore'; + import canUseDOM from '../canUseDom'; + +@@ -225,7 +225,7 @@ function eventListener(domEvent) { + } + return; + } +- var isStartEvent = isStartish(eventType) && isPrimaryPointerDown(domEvent); ++ var isStartEvent = isStartish(eventType) && isPrimaryOrSecondaryPointerDown(domEvent); + var isMoveEvent = isMoveish(eventType); + var isEndEvent = isEndish(eventType); + var isScrollEvent = isScroll(eventType); +diff --git a/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js b/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js +index 7382cdd..d88f6c0 100644 +--- a/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js ++++ b/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js +@@ -148,14 +148,14 @@ export function hasValidSelection(domEvent) { + /** + * Events are only valid if the primary button was used without specific modifier keys. + */ +-export function isPrimaryPointerDown(domEvent) { ++export function isPrimaryOrSecondaryPointerDown(domEvent) { + var altKey = domEvent.altKey, + button = domEvent.button, + buttons = domEvent.buttons, + ctrlKey = domEvent.ctrlKey, + type = domEvent.type; + var isTouch = type === 'touchstart' || type === 'touchmove'; +- var isPrimaryMouseDown = type === 'mousedown' && (button === 0 || buttons === 1); ++ var isPrimaryMouseDown = type === 'mousedown' && (button === 0 || buttons === 1 || buttons === 2); + var isPrimaryMouseMove = type === 'mousemove' && buttons === 1; + var noModifiers = altKey === false && ctrlKey === false; + if (isTouch || isPrimaryMouseDown && noModifiers || isPrimaryMouseMove && noModifiers) { diff --git a/src/CONST.ts b/src/CONST.ts index 3c53f083abac..af4864c22a85 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -630,6 +630,7 @@ const CONST = { EXPORTEDTOQUICKBOOKS: 'EXPORTEDTOQUICKBOOKS', // OldDot Action FORWARDED: 'FORWARDED', // OldDot Action HOLD: 'HOLD', + HOLDCOMMENT: 'HOLDCOMMENT', IOU: 'IOU', INTEGRATIONSMESSAGE: 'INTEGRATIONSMESSAGE', // OldDot Action MANAGERATTACHRECEIPT: 'MANAGERATTACHRECEIPT', // OldDot Action diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 5681be838ca8..026025593aef 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -25,12 +25,10 @@ import Navigation from './libs/Navigation/Navigation'; import NavigationRoot from './libs/Navigation/NavigationRoot'; import NetworkConnection from './libs/NetworkConnection'; import PushNotification from './libs/Notification/PushNotification'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars import './libs/Notification/PushNotification/subscribePushNotification'; import StartupTimer from './libs/StartupTimer'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import UnreadIndicatorUpdater from './libs/UnreadIndicatorUpdater'; +import './libs/UnreadIndicatorUpdater'; import Visibility from './libs/Visibility'; import ONYXKEYS from './ONYXKEYS'; import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu'; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d74e691fe10e..d3fab1b9fcde 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -353,6 +353,8 @@ const ONYXKEYS = { WORKSPACE_TAX_CUSTOM_NAME_DRAFT: 'workspaceTaxCustomNameDraft', POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm', POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft', + POLICY_DISTANCE_RATE_EDIT_FORM: 'policyDistanceRateEditForm', + POLICY_DISTANCE_RATE_EDIT_FORM_DRAFT: 'policyDistanceRateEditFormDraft', CLOSE_ACCOUNT_FORM: 'closeAccount', CLOSE_ACCOUNT_FORM_DRAFT: 'closeAccountDraft', PROFILE_SETTINGS_FORM: 'profileSettingsForm', @@ -482,6 +484,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.POLICY_TAG_NAME_FORM]: FormTypes.PolicyTagNameForm; [ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm; [ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM]: FormTypes.PolicyCreateDistanceRateForm; + [ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_EDIT_FORM]: FormTypes.PolicyDistanceRateEditForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_NAME_FORM]: FormTypes.WorkspaceTaxNameForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm; }; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 1f802c5036e3..c216d5ac288c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -204,7 +204,7 @@ const ROUTES = { }, REPORT_ATTACHMENTS: { route: 'r/:reportID/attachment', - getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}` as const, + getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURIComponent(source)}` as const, }, REPORT_PARTICIPANTS: { route: 'r/:reportID/participants', @@ -304,13 +304,10 @@ const ROUTES = { route: ':iouType/new/receipt/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}` as const, }, - MONEY_REQUEST_DISTANCE: { - route: ':iouType/new/address/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}` as const, - }, MONEY_REQUEST_CREATE: { - route: 'create/:iouType/start/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}` as const, + route: ':action/:iouType/start/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}` as const, }, MONEY_REQUEST_STEP_CONFIRMATION: { route: 'create/:iouType/confirmation/:transactionID/:reportID', @@ -352,9 +349,9 @@ const ROUTES = { getUrlWithBackToParam(`${action}/${iouType}/description/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), }, MONEY_REQUEST_STEP_DISTANCE: { - route: 'create/:iouType/distance/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/distance/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/distance/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/distance/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_MERCHANT: { route: ':action/:iouType/merchant/:transactionID/:reportID', @@ -395,16 +392,19 @@ const ROUTES = { getRoute: (iouType: ValueOf, iouRequestType: ValueOf) => `start/${iouType}/${iouRequestType}` as const, }, MONEY_REQUEST_CREATE_TAB_DISTANCE: { - route: 'create/:iouType/start/:transactionID/:reportID/distance', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/distance` as const, + route: ':action/:iouType/start/:transactionID/:reportID/distance', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}/distance` as const, }, MONEY_REQUEST_CREATE_TAB_MANUAL: { - route: 'create/:iouType/start/:transactionID/:reportID/manual', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/manual` as const, + route: ':action/:iouType/start/:transactionID/:reportID/manual', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}/manual` as const, }, MONEY_REQUEST_CREATE_TAB_SCAN: { - route: 'create/:iouType/start/:transactionID/:reportID/scan', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/scan` as const, + route: ':action/:iouType/start/:transactionID/:reportID/scan', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}/scan` as const, }, IOU_REQUEST: 'request/new', @@ -637,8 +637,16 @@ const ROUTES = { getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates/new` as const, }, WORKSPACE_DISTANCE_RATES_SETTINGS: { - route: 'settings/workspace/:policyID/distance-rates/settings', - getRoute: (policyID: string) => `settings/workspace/${policyID}/distance-rates/settings` as const, + route: 'settings/workspaces/:policyID/distance-rates/settings', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates/settings` as const, + }, + WORKSPACE_DISTANCE_RATE_DETAILS: { + route: 'settings/workspaces/:policyID/distance-rates/:rateID', + getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}` as const, + }, + WORKSPACE_DISTANCE_RATE_EDIT: { + route: 'settings/workspaces/:policyID/distance-rates/:rateID/edit', + getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/edit` as const, }, // Referral program promotion REFERRAL_DETAILS_MODAL: { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 4d4e9ea327c6..82fef0383918 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -155,7 +155,6 @@ const SCREENS = { CURRENCY: 'Money_Request_Currency', WAYPOINT: 'Money_Request_Waypoint', EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint', - DISTANCE: 'Money_Request_Distance', RECEIPT: 'Money_Request_Receipt', }, @@ -246,6 +245,8 @@ const SCREENS = { DISTANCE_RATES: 'Distance_Rates', CREATE_DISTANCE_RATE: 'Create_Distance_Rate', DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings', + DISTANCE_RATE_DETAILS: 'Distance_Rate_Details', + DISTANCE_RATE_EDIT: 'Distance_Rate_Edit', }, EDIT_REQUEST: { diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js index b934bdfdd738..9524c5203110 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js +++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js @@ -15,10 +15,19 @@ import CONST from '@src/CONST'; function extractAttachmentsFromReport(parentReportAction, reportActions) { const actions = [parentReportAction, ...ReportActionsUtils.getSortedReportActions(_.values(reportActions))]; const attachments = []; + // We handle duplicate image sources by considering the first instance as original. Selecting any duplicate + // and navigating back (<) shows the image preceding the first instance, not the selected duplicate's position. + const uniqueSources = new Set(); const htmlParser = new HtmlParser({ onopentag: (name, attribs) => { if (name === 'video') { + const source = tryResolveUrlFromApiRoot(attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]); + if (uniqueSources.has(source)) { + return; + } + + uniqueSources.add(source); const splittedUrl = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE].split('/'); attachments.unshift({ reportActionID: null, @@ -35,7 +44,20 @@ function extractAttachmentsFromReport(parentReportAction, reportActions) { if (name === 'img' && attribs.src) { const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]; const source = tryResolveUrlFromApiRoot(expensifySource || attribs.src); - const fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`); + if (uniqueSources.has(source)) { + return; + } + + uniqueSources.add(source); + let fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`); + + // Public image URLs might lack a file extension in the source URL, without an extension our + // AttachmentView fails to recognize them as images and renders fallback content instead. + // We apply this small hack to add an image extension and ensure AttachmentView renders the image. + const fileInfo = FileUtils.splitExtensionFromFileName(fileName); + if (!fileInfo.fileExtension) { + fileName = `${fileInfo.fileName || 'image'}.jpg`; + } // By iterating actions in chronological order and prepending each attachment // we ensure correct order of attachments even across actions with multiple attachments. diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index 461548f0d2b1..9fe37734e8ee 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -79,6 +79,7 @@ const defaultProps = { reportActionID: '', isHovered: false, optionalVideoDuration: 0, + fallbackSource: Expensicons.Gallery, }; function AttachmentView({ @@ -201,6 +202,21 @@ function AttachmentView({ // We also check for numeric source since this is how static images (used for preview) are represented in RN. const isImage = typeof source === 'number' || Str.isImage(source); if (isImage || (file && Str.isImage(file.name))) { + if (imageError) { + // AttachmentViewImage can't handle icon fallbacks, so we need to handle it here + if (typeof fallbackSource === 'number' || _.isFunction(fallbackSource)) { + return ( + + ); + } + } + return ( ({ success={success} ref={buttonRef} pressOnEnter={pressOnEnter} - isDisabled={isDisabled} + isDisabled={isDisabled || !!options[0].disabled} style={[styles.w100, style]} isLoading={isLoading} text={selectedItem.text} diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 83100788761f..87db9a29d827 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -22,6 +22,7 @@ type DropdownOption = { iconHeight?: number; iconDescription?: string; onSelected?: () => void; + disabled?: boolean; }; type ButtonWithDropdownMenuProps = { diff --git a/src/components/CustomStatusBarAndBackground/index.tsx b/src/components/CustomStatusBarAndBackground/index.tsx index 356fbd3726a3..524c8a3903e0 100644 --- a/src/components/CustomStatusBarAndBackground/index.tsx +++ b/src/components/CustomStatusBarAndBackground/index.tsx @@ -114,6 +114,11 @@ function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBack [prevIsRootStatusBarEnabled, isRootStatusBarEnabled, statusBarAnimation, statusBarStyle, theme.PAGE_THEMES, theme.appBG, theme.statusBarStyle], ); + useEffect(() => { + updateStatusBarAppearance({backgroundColor: theme.appBG}); + // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this to run on first render + }, []); + useEffect(() => { didForceUpdateStatusBarRef.current = false; }, [isRootStatusBarEnabled]); diff --git a/src/components/DistanceRequest/index.tsx b/src/components/DistanceRequest/index.tsx deleted file mode 100644 index f9e8c0be12ff..000000000000 --- a/src/components/DistanceRequest/index.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import type {RouteProp} from '@react-navigation/native'; -import lodashIsEqual from 'lodash/isEqual'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -import type {ScrollView} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/Button'; -import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import DraggableList from '@components/DraggableList'; -import type {DraggableListData} from '@components/DraggableList/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import usePrevious from '@hooks/usePrevious'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as IOUUtils from '@libs/IOUUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import * as TransactionUtils from '@libs/TransactionUtils'; -import * as MapboxToken from '@userActions/MapboxToken'; -import * as TransactionUserActions from '@userActions/Transaction'; -import * as TransactionEdit from '@userActions/TransactionEdit'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {Report, Transaction} from '@src/types/onyx'; -import type {WaypointCollection} from '@src/types/onyx/Transaction'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import DistanceRequestFooter from './DistanceRequestFooter'; -import DistanceRequestRenderItem from './DistanceRequestRenderItem'; - -type DistanceRequestOnyxProps = { - transaction: OnyxEntry; -}; - -type DistanceRequestProps = DistanceRequestOnyxProps & { - /** The TransactionID of this request */ - transactionID?: string; - - /** The report to which the distance request is associated */ - report: OnyxEntry; - - /** Are we editing an existing distance request, or creating a new one? */ - isEditingRequest?: boolean; - - /** Are we editing the distance while creating a new distance request */ - isEditingNewRequest?: boolean; - - /** Called on submit of this page */ - onSubmit: (waypoints?: WaypointCollection) => void; - - /** React Navigation route */ - route: RouteProp<{ - /** Params from the route */ - params: { - /** The type of IOU report, i.e. bill, request, send */ - iouType: string; - /** The report ID of the IOU */ - reportID: string; - }; - }>; -}; - -function DistanceRequest({transactionID = '', report, transaction, route, isEditingRequest = false, isEditingNewRequest = false, onSubmit}: DistanceRequestProps) { - const styles = useThemeStyles(); - const {isOffline} = useNetwork(); - const {translate} = useLocalize(); - - const [optimisticWaypoints, setOptimisticWaypoints] = useState(); - const [hasError, setHasError] = useState(false); - const reportID = report?.reportID ?? ''; - const waypoints: WaypointCollection = useMemo(() => optimisticWaypoints ?? transaction?.comment?.waypoints ?? {waypoint0: {}, waypoint1: {}}, [optimisticWaypoints, transaction]); - const waypointsList = Object.keys(waypoints); - const iouType = route?.params?.iouType ?? ''; - const previousWaypoints = usePrevious(waypoints); - const numberOfWaypoints = Object.keys(waypoints).length; - const numberOfPreviousWaypoints = Object.keys(previousWaypoints).length; - const scrollViewRef = useRef(null); - - const isLoadingRoute = transaction?.comment?.isLoading ?? false; - const isLoading = transaction?.isLoading ?? false; - const hasRouteError = Boolean(transaction?.errorFields?.route); - const hasRoute = TransactionUtils.hasRoute((transaction ?? {}) as Transaction); - const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); - const previousValidatedWaypoints = usePrevious(validatedWaypoints); - const haveValidatedWaypointsChanged = !lodashIsEqual(previousValidatedWaypoints, validatedWaypoints); - const isRouteAbsentWithoutErrors = !hasRoute && !hasRouteError; - const shouldFetchRoute = (isRouteAbsentWithoutErrors || haveValidatedWaypointsChanged) && !isLoadingRoute && Object.keys(validatedWaypoints).length > 1; - const transactionWasSaved = useRef(false); - - useEffect(() => { - MapboxToken.init(); - return MapboxToken.stop; - }, []); - - useEffect(() => { - if (!isEditingNewRequest && !isEditingRequest) { - return () => {}; - } - // This effect runs when the component is mounted and unmounted. It's purpose is to be able to properly - // discard changes if the user cancels out of making any changes. This is accomplished by backing up the - // original transaction, letting the user modify the current transaction, and then if the user ever - // cancels out of the modal without saving changes, the original transaction is restored from the backup. - - // On mount, create the backup transaction. - TransactionEdit.createBackupTransaction(transaction); - - return () => { - // If the user cancels out of the modal without without saving changes, then the original transaction - // needs to be restored from the backup so that all changes are removed. - if (transactionWasSaved.current) { - return; - } - TransactionEdit.restoreOriginalTransactionFromBackup(transaction?.transactionID ?? ''); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - const transactionWaypoints = transaction?.comment?.waypoints ?? {}; - if (!transaction?.transactionID || Object.keys(transactionWaypoints).length) { - return; - } - - // Create the initial start and stop waypoints - TransactionUserActions.createInitialWaypoints(transactionID); - return () => { - // Whenever we reset the transaction, we need to set errors as empty/false. - setHasError(false); - }; - }, [transaction, transactionID]); - - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (isOffline || !shouldFetchRoute) { - return; - } - - TransactionUserActions.getRoute(transactionID, validatedWaypoints); - }, [shouldFetchRoute, transactionID, validatedWaypoints, isOffline]); - - useEffect(() => { - if (numberOfWaypoints <= numberOfPreviousWaypoints) { - return; - } - scrollViewRef.current?.scrollToEnd({animated: true}); - }, [numberOfPreviousWaypoints, numberOfWaypoints]); - - useEffect(() => { - // Whenever we change waypoints we need to remove the error or it will keep showing the error. - if (lodashIsEqual(previousWaypoints, waypoints)) { - return; - } - setHasError(false); - }, [waypoints, previousWaypoints]); - - const navigateBack = () => { - Navigation.goBack(isEditingNewRequest ? ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID) : ROUTES.HOME); - }; - - /** - * Takes the user to the page for editing a specific waypoint - */ - const navigateToWaypointEditPage = (index: number) => { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute( - CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.REQUEST, - transactionID, - report?.reportID ?? '', - index.toString(), - Navigation.getActiveRouteWithoutParams(), - ), - ); - }; - - const getError = useCallback(() => { - // Get route error if available else show the invalid number of waypoints error. - if (hasRouteError) { - return ErrorUtils.getLatestErrorField((transaction ?? {}) as Transaction, 'route'); - } - - if (Object.keys(validatedWaypoints).length < 2) { - // eslint-disable-next-line @typescript-eslint/naming-convention - return {0: 'iou.error.atLeastTwoDifferentWaypoints'}; - } - - if (Object.keys(validatedWaypoints).length < Object.keys(waypoints).length) { - // eslint-disable-next-line @typescript-eslint/naming-convention - return {0: translate('iou.error.duplicateWaypointsErrorMessage')}; - } - }, [translate, transaction, hasRouteError, validatedWaypoints, waypoints]); - - const updateWaypoints = useCallback( - ({data}: DraggableListData) => { - if (lodashIsEqual(waypointsList, data)) { - return; - } - - const newWaypoints: WaypointCollection = {}; - let emptyWaypointIndex = -1; - data.forEach((waypoint, index) => { - newWaypoints[`waypoint${index}`] = waypoints?.[waypoint] ?? {}; - // Find waypoint that BECOMES empty after dragging - if (isEmptyObject(newWaypoints[`waypoint${index}`]) && !isEmptyObject(waypoints[`waypoint${index}`])) { - emptyWaypointIndex = index; - } - }); - - setOptimisticWaypoints(newWaypoints); - // eslint-disable-next-line rulesdir/no-thenable-actions-in-views - Promise.all([TransactionUserActions.removeWaypoint(transaction, emptyWaypointIndex.toString()), TransactionUserActions.updateWaypoints(transactionID, newWaypoints)]).then(() => { - setOptimisticWaypoints(undefined); - }); - }, - [transactionID, transaction, waypoints, waypointsList], - ); - - const submitWaypoints = useCallback(() => { - // If there is any error or loading state, don't let user go to next page. - if (!isEmptyObject(getError()) || isLoadingRoute || (isLoading && !isOffline)) { - setHasError(true); - return; - } - - if (isEditingNewRequest || isEditingRequest) { - transactionWasSaved.current = true; - } - - onSubmit(waypoints); - }, [onSubmit, setHasError, getError, isLoadingRoute, isLoading, waypoints, isEditingNewRequest, isEditingRequest, isOffline]); - - const content = ( - <> - - item} - shouldUsePortal - onDragEnd={updateWaypoints} - ref={scrollViewRef} - renderItem={({item, drag, isActive, getIndex}) => ( - number} - onPress={navigateToWaypointEditPage} - disabled={isLoadingRoute} - /> - )} - ListFooterComponent={ - - } - /> - - - {/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */} - {((hasError && !isEmptyObject(getError())) || hasRouteError) && ( - - )} -