diff --git a/.github/scripts/detectRedirectCycle.ts b/.github/scripts/detectRedirectCycle.ts new file mode 100644 index 000000000000..5aa0d1daf342 --- /dev/null +++ b/.github/scripts/detectRedirectCycle.ts @@ -0,0 +1,64 @@ +import {parse} from 'csv-parse'; +import fs from 'fs'; + +const parser = parse(); + +const adjacencyList: Record<string, string[]> = {}; +const visited: Map<string, boolean> = new Map<string, boolean>(); +const backEdges: Map<string, boolean> = new Map<string, boolean>(); + +function addEdge(source: string, target: string) { + if (!adjacencyList[source]) { + adjacencyList[source] = []; + } + adjacencyList[source].push(target); +} + +function isCyclic(currentNode: string): boolean { + visited.set(currentNode, true); + backEdges.set(currentNode, true); + + // Do a depth first search for all the neighbours. If a node is found in backedge, a cycle is detected. + const neighbours = adjacencyList[currentNode]; + if (neighbours) { + for (const node of neighbours) { + if (!visited.has(node)) { + if (isCyclic(node)) { + return true; + } + } else if (backEdges.has(node)) { + return true; + } + } + } + + backEdges.delete(currentNode); + + return false; +} + +function detectCycle(): boolean { + for (const [node] of Object.entries(adjacencyList)) { + if (!visited.has(node)) { + if (isCyclic(node)) { + const cycle = Array.from(backEdges.keys()); + console.log(`Infinite redirect found in the cycle: ${cycle.join(' -> ')} -> ${node}`); + return true; + } + } + } + return false; +} + +fs.createReadStream(`${process.cwd()}/docs/redirects.csv`) + .pipe(parser) + .on('data', (row) => { + // Create a directed graph of sourceURL -> targetURL + addEdge(row[0], row[1]); + }) + .on('end', () => { + if (detectCycle()) { + process.exit(1); + } + process.exit(0); + }); diff --git a/.github/scripts/verifyRedirect.sh b/.github/scripts/verifyRedirect.sh old mode 100644 new mode 100755 index 737d9bffacf9..b8942cd5b23d --- a/.github/scripts/verifyRedirect.sh +++ b/.github/scripts/verifyRedirect.sh @@ -5,11 +5,22 @@ declare -r REDIRECTS_FILE="docs/redirects.csv" +declare -r RED='\033[0;31m' +declare -r GREEN='\033[0;32m' +declare -r NC='\033[0m' + duplicates=$(awk -F, 'a[$1]++{print $1}' $REDIRECTS_FILE) +if [[ -n "$duplicates" ]]; then + echo "${RED}duplicate redirects are not allowed: $duplicates ${NC}" + exit 1 +fi -if [[ -z "$duplicates" ]]; then - exit 0 +npm run detectRedirectCycle +DETECT_CYCLE_EXIT_CODE=$? +if [[ DETECT_CYCLE_EXIT_CODE -eq 1 ]]; then + echo -e "${RED}The redirects.csv has a cycle. Please remove the redirect cycle because it will cause an infinite redirect loop ${NC}" + exit 1 fi -echo "duplicate redirects are not allowed: $duplicates" -exit 1 +echo -e "${GREEN}The redirects.csv is valid!${NC}" +exit 0 diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index 699bd379fb77..f7f826d66f9b 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -36,8 +36,8 @@ jobs: - name: Create docs routes file run: ./.github/scripts/createDocsRoutes.sh - - - name: Check duplicates in redirect.csv + + - name: Check for duplicates and cycles in redirects.csv run: ./.github/scripts/verifyRedirect.sh - name: Build with Jekyll diff --git a/android/app/build.gradle b/android/app/build.gradle index b8350a8a0111..47803b00c58e 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 1001046307 - versionName "1.4.63-7" + versionCode 1001046313 + versionName "1.4.63-13" // 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/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md b/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md new file mode 100644 index 000000000000..7c3d8077c14d --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md @@ -0,0 +1,111 @@ +--- +title: Enable and set up expense violations +description: Set up rules for expenses and enable violations +--- +<div id="expensify-classic" markdown="1"> + +Expensify automatically detects expense errors or discrepancies as violations that must be corrected. You can also set rules for a workspace that will trigger a violation if the rule is not met. These rules can be set for categories, tags, and even for specific domain groups. + +When reviewing submitted expense reports, approvers will see violations highlighted with an exclamation mark. There are two types of violations: +- **Yellow**: Automated highlights that require attention but may not require corrective action. For example, if a receipt was SmartScanned and then the amount was modified, a yellow violation will be added to call out the change for review. +- **Red**: Violations directly tied to your workspace settings. These violations must be addressed before the report can be submitted and reimbursed. + +You can hover over the icon to see a brief description, and you can find more detailed information below the list of expenses. + +{% include info.html %} +If your workspace has automations set to automatically submit reports for approval, the report that contains violations will not be submitted automatically until the violations are corrected. (However, if a comment is added to an expense, it will override the violation as the member is providing a reason for submission *unless* domain workspace rules are set to be strictly enforced, as detailed near the bottom of this article.) +{% include end-info.html %} + +# Enable or disable expense violations + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Expenses** tab on the left. +5. Click the “Enable violations” toggle. +6. If desired, enter the expense rules that will be used to create violations: + - **Max expense age (days)**: How old an expense can be + - **Max expense amount**: How much a single expense can cost + - **Receipt required amount**: How much a single expense can cost before a receipt is required + +{% include info.html %} +Expensify includes certain system mandatory violations that can't be disabled, even if your policy has violations turned off. +{% include end-info.html %} + +# Set category rules + +Admins on a Control workspace can enable specific rules for each category, including setting expense caps for specific categories, requiring receipts, and more. These rules can allow you to have a default expense limit of $2,500 but to only allow a daily entertainment limit of $150 per person. You can also choose to not require receipts for mileage or per diem expenses. + +To set up category rules, +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Categories** tab on the left. +5. Click **Edit** to the right of the category. +6. Enter your category rules, as desired: + - **GL Code and Payroll Code**: You can add general ledger (GL) or payroll codes to the category for accounting. GL codes populate automatically if you have an accounting integration connected with Expensify. + - **Max Amount**: You can set specific expense caps for the expense category. Use the Limit Type dropdown to determine if the amount is set per individual expense or per day, then enter the maximum amount into this field. + - **Receipts**: You can determine whether receipts are required for the category. For example, many companies disable receipt requirements for toll expenses. + - **Description**: You can determine whether a description is required for expenses under this category. + - **Description Hint**: You can add a hint in the description field to prompt the expense creator on what they should enter into the description field for expenses under this category. + - **Approver**: You can set a specific approver for expenses labeled with this category. + +If users are in violation of these rules, the violations will be shown in red on the report. + +{% include info.html %} +If Scheduled Submit is enabled on a workspace, expenses with category violations will not be auto-submitted unless the expense has a comment added. +{% include end-info.html %} + +# Make categories required + +This means all expenses must be coded with a Category. + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Categories** tab on the left. +5. Enable the “People must categorize expenses” toggle. + +Each Workspace Member will now be required to select a category for their expense. If they do not select a category, the report will receive a violation, which can prevent submission if Scheduled Submit is enabled. + +# Make tags required + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Tags** tab on the left. +5. Enable the “People must tag expenses” toggle. + +Each Workspace Member will now be required to select a tag for their expense before they’re able to submit it. + +# Require strict compliance by domain group + +You can require strict compliance to require members of a specific domain group to submit reports that meet **all** workspace rules before they can submit their expense report—even if they add a note. Every rule and regulation on the workspace must be met before a report can be submitted. + +{% include info.html %} +This will prevent members from submitting any reports where a manager has granted them a manual exception for any of the workspace rules. +{% include end-info.html %} + +To enable strict domain group compliance for reports, + +1. Hover over Settings, then click **Domains**. +2. Click the **Groups** tab on the left. +3. Click **Edit** to the right of the desired workspace name. +4. Enable the “Strictly enforce expense workspace rules” toggle. + +# FAQs + +**Why can’t my employees see the categories on their expenses?** + +The employee may have their default workspace set as their personal workspace. Look under the details section on top right of the report to ensure it is being reported under the correct workspace. + +**Will the account numbers from our accounting system (QuickBooks Online, Sage Intacct, etc.) show in the category list for employees?** + +The general ledger (GL) account numbers are visible only for Workspace Admins in the workspace settings when they are part of a control workspace. This information is not visible to other members of the workspace. However, if you wish to have this information available to your employees when they are categorizing their expenses, you can edit the account name in your accounting software to include the GL number (for example, Accounts Payable - 12345). + +**What causes a category violation?** + +- An expense is categorized with a category that is not included in the workspace's categories. This may happen if the employee creates an expense under the wrong workspace, which will cause a "category out of workspace" violation. +- If the workspace categories are being imported from an accounting integration and they’ve been updated in the accounting system but not in Expensify, this can cause an old category to still be in use on an open report which would throw a violation on submission. Simply reselect a proper category to clear violation. + +</div> diff --git a/docs/redirects.csv b/docs/redirects.csv index af595ecc5f83..95404c2326a0 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -87,6 +87,7 @@ https://help.expensify.com/articles/new-expensify/payments/Request-Money,https:/ https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/tax-tracking,https://help.expensify.com/articles/expensify-classic/expenses/expenses/Apply-Tax https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/User-Roles.html,https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/ https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Owner,https://help.expensify.com/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account +https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Owner.html,https://help.expensify.com/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Other-Export-Options.html,https://help.expensify.com/articles/expensify-classic/spending-insights/Other-Export-Options https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Global-Reimbursements https://help.expensify.com/expensify-classic/hubs/bank-accounts-and-credit-cards,https://help.expensify.com/expensify-classic/hubs/ @@ -123,7 +124,6 @@ https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/ https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Tax-Exempt,https://help.expensify.com/articles/expensify-classic/expensify-billing/Tax-Exempt https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Approving-Reports,https://help.expensify.com/expensify-classic/hubs/reports/ https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Invite-Members,https://help.expensify.com/articles/expensify-classic/workspaces/Invite-members-and-assign-roles -https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Removing-Members,https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Removing-Members https://help.expensify.com/articles/expensify-classic/expense-and-report-features/Attendee-Tracking,https://help.expensify.com/articles/expensify-classic/expenses/Track-group-expenses https://help.expensify.com/articles/expensify-classic/expense-and-report-features/Currency,https://help.expensify.com/articles/expensify-classic/workspaces/Currency https://help.expensify.com/articles/expensify-classic/expense-and-report-features/Expense-Rules,https://help.expensify.com/articles/expensify-classic/expenses/Expense-Rules @@ -158,3 +158,5 @@ https://help.expensify.com/articles/expensify-classic/workspaces/Budgets,https:/ https://help.expensify.com/articles/expensify-classic/workspaces/Categories,https://help.expensify.com/articles/expensify-classic/workspaces/Create-categories https://help.expensify.com/articles/expensify-classic/workspaces/Tags,https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags https://help.expensify.com/expensify-classic/hubs/manage-employees-and-report-approvals,https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Approval-Workflows +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 diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 8b911fa849cd..b224296ed75a 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -6,7 +6,7 @@ layout: {% assign pages = site.html_pages | where_exp:'doc','doc.sitemap != false' | where_exp:'doc','doc.url != "/404.html"' %} {% for page in pages %} <url> - <loc>{{ page.url | replace:'/index.html','/' | absolute_url | xml_escape }}</loc> + <loc>{{ page.url | replace:'/index.html','/' | absolute_url | xml_escape | replace:'.html','' }}</loc> </url> {% endfor %} </urlset> \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 4523bf1c4418..1df39b44ddbc 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ </dict> </array> <key>CFBundleVersion</key> - <string>1.4.63.7</string> + <string>1.4.63.13</string> <key>ITSAppUsesNonExemptEncryption</key> <false/> <key>LSApplicationQueriesSchemes</key> diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index d40d0fa27486..a89a6217c181 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> - <string>1.4.63.7</string> + <string>1.4.63.13</string> </dict> </plist> diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index d472daa53ab7..28a707e6771f 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ <key>CFBundleShortVersionString</key> <string>1.4.63</string> <key>CFBundleVersion</key> - <string>1.4.63.7</string> + <string>1.4.63.13</string> <key>NSExtension</key> <dict> <key>NSExtensionPointIdentifier</key> diff --git a/package-lock.json b/package-lock.json index 2c2d67274c94..2fd90219d5cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.63-7", + "version": "1.4.63-13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.63-7", + "version": "1.4.63-13", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -199,6 +199,7 @@ "concurrently": "^8.2.2", "copy-webpack-plugin": "^10.1.0", "css-loader": "^6.7.2", + "csv-parse": "^5.5.5", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", "electron": "^29.2.0", @@ -17733,6 +17734,12 @@ "version": "3.1.1", "license": "MIT" }, + "node_modules/csv-parse": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.5.tgz", + "integrity": "sha512-erCk7tyU3yLWAhk6wvKxnyPtftuy/6Ak622gOO7BCJ05+TYffnPCJF905wmOQm+BpkX54OdAl8pveJwUdpnCXQ==", + "dev": true + }, "node_modules/dag-map": { "version": "1.0.2", "license": "MIT" diff --git a/package.json b/package.json index 72e6fb2a746e..317c7c27505e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.63-7", + "version": "1.4.63-13", "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.", @@ -28,6 +28,7 @@ "desktop-build": "scripts/build-desktop.sh production", "desktop-build-staging": "scripts/build-desktop.sh staging", "createDocsRoutes": "ts-node .github/scripts/createDocsRoutes.ts", + "detectRedirectCycle": "ts-node .github/scripts/detectRedirectCycle.ts", "desktop-build-adhoc": "scripts/build-desktop.sh adhoc", "ios-build": "fastlane ios build", "android-build": "fastlane android build", @@ -250,6 +251,7 @@ "concurrently": "^8.2.2", "copy-webpack-plugin": "^10.1.0", "css-loader": "^6.7.2", + "csv-parse": "^5.5.5", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", "electron": "^29.2.0", diff --git a/patches/react-native+0.73.4+014+fix-inverted-flatlist.patch b/patches/react-native+0.73.4+014+fix-inverted-flatlist.patch new file mode 100644 index 000000000000..7bed06d01913 --- /dev/null +++ b/patches/react-native+0.73.4+014+fix-inverted-flatlist.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp b/node_modules/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp +index a8ecce5..6ad790e 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp +@@ -66,7 +66,17 @@ void ScrollViewShadowNode::layout(LayoutContext layoutContext) { + Point ScrollViewShadowNode::getContentOriginOffset() const { + auto stateData = getStateData(); + auto contentOffset = stateData.contentOffset; +- return {-contentOffset.x, -contentOffset.y + stateData.scrollAwayPaddingTop}; ++ auto props = getConcreteProps(); ++ ++ float productX = 1.0f; ++ float productY = 1.0f; ++ ++ for (const auto& operation : props.transform.operations) { ++ productX *= operation.x; ++ productY *= operation.y; ++ } ++ ++ return {-contentOffset.x * productX, (-contentOffset.y + stateData.scrollAwayPaddingTop) * productY}; + } + + } // namespace facebook::react diff --git a/patches/react-native-screens+3.30.1+001+fix-screen-type.patch b/patches/react-native-screens+3.30.1+001+fix-screen-type.patch new file mode 100644 index 000000000000..f282ec58b07b --- /dev/null +++ b/patches/react-native-screens+3.30.1+001+fix-screen-type.patch @@ -0,0 +1,12 @@ +diff --git a/node_modules/react-native-screens/src/components/Screen.tsx b/node_modules/react-native-screens/src/components/Screen.tsx +index 3f9a1cb..45767f7 100644 +--- a/node_modules/react-native-screens/src/components/Screen.tsx ++++ b/node_modules/react-native-screens/src/components/Screen.tsx +@@ -79,6 +79,7 @@ export class InnerScreen extends React.Component<ScreenProps> { + // Due to how Yoga resolves layout, we need to have different components for modal nad non-modal screens + const AnimatedScreen = + Platform.OS === 'android' || ++ stackPresentation === undefined || + stackPresentation === 'push' || + stackPresentation === 'containedModal' || + stackPresentation === 'containedTransparentModal' diff --git a/src/CONST.ts b/src/CONST.ts index a840cb481a1a..e6bfe8f9a379 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -6,9 +6,10 @@ import * as KeyCommand from 'react-native-key-command'; import type {ValueOf} from 'type-fest'; import * as Url from './libs/Url'; import SCREENS from './SCREENS'; +import type {Unit} from './types/onyx/Policy'; type RateAndUnit = { - unit: string; + unit: Unit; rate: number; }; type CurrencyDefaultMileageRate = Record<string, RateAndUnit>; @@ -4351,6 +4352,6 @@ type Country = keyof typeof CONST.ALL_COUNTRIES; type IOUType = ValueOf<typeof CONST.IOU.TYPE>; type IOUAction = ValueOf<typeof CONST.IOU.ACTION>; -export type {Country, IOUAction, IOUType}; +export type {Country, IOUAction, IOUType, RateAndUnit}; export default CONST; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 51d48ded6c9b..1a17a6c19114 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -367,6 +367,11 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => getUrlWithBackToParam(`${action as string}/${iouType as string}/distance/${transactionID}/${reportID}`, backTo), }, + MONEY_REQUEST_STEP_DISTANCE_RATE: { + route: ':action/:iouType/distanceRate/:transactionID/:reportID', + getRoute: (action: ValueOf<typeof CONST.IOU.ACTION>, iouType: ValueOf<typeof CONST.IOU.TYPE>, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/distanceRate/${transactionID}/${reportID}`, backTo), + }, MONEY_REQUEST_STEP_MERCHANT: { route: ':action/:iouType/merchant/:transactionID/:reportID', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 3ada9ce1a2c6..667e3fdebbf1 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -152,6 +152,7 @@ const SCREENS = { STEP_DATE: 'Money_Request_Step_Date', STEP_DESCRIPTION: 'Money_Request_Step_Description', STEP_DISTANCE: 'Money_Request_Step_Distance', + STEP_DISTANCE_RATE: 'Money_Request_Step_Rate', STEP_MERCHANT: 'Money_Request_Step_Merchant', STEP_PARTICIPANTS: 'Money_Request_Step_Participants', STEP_SCAN: 'Money_Request_Step_Scan', diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index e90cb7584c43..e309df1ab654 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -8,13 +8,14 @@ import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import type {DefaultMileageRate} from '@libs/DistanceRequestUtils'; +import type {MileageRate} from '@libs/DistanceRequestUtils'; import * as IOUUtils from '@libs/IOUUtils'; import Log from '@libs/Log'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; @@ -65,7 +66,13 @@ type MoneyRequestConfirmationListOnyxProps = { session: OnyxEntry<OnyxTypes.Session>; /** Unit and rate used for if the expense is a distance expense */ - mileageRate: OnyxEntry<DefaultMileageRate>; + mileageRates: OnyxEntry<Record<string, MileageRate>>; + + /** Mileage rate default for the policy */ + defaultMileageRate: OnyxEntry<MileageRate>; + + /** Last selected distance rates */ + lastSelectedDistanceRates: OnyxEntry<Record<string, string>>; }; type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & { @@ -180,7 +187,7 @@ function MoneyRequestConfirmationList({ isScanRequest = false, iouAmount, policyCategories, - mileageRate, + mileageRates, isDistanceRequest = false, policy, isPolicyExpenseChat = false, @@ -208,28 +215,50 @@ function MoneyRequestConfirmationList({ onToggleBillable, hasSmartScanFailed, reportActionID, + defaultMileageRate, + lastSelectedDistanceRates, action = CONST.IOU.ACTION.CREATE, }: MoneyRequestConfirmationListProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate, toLocaleDigit} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {canUseViolations} = usePermissions(); + const {canUseP2PDistanceRequests, canUseViolations} = usePermissions(iouType); + const {isOffline} = useNetwork(); const isTypeRequest = iouType === CONST.IOU.TYPE.REQUEST; const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; const isTypeSend = iouType === CONST.IOU.TYPE.SEND; const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK_EXPENSE; - const {unit, rate, currency} = mileageRate ?? { - unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - rate: 0, - currency: CONST.CURRENCY.USD, - }; - const distance = transaction?.routes?.route0.distance ?? 0; - const shouldCalculateDistanceAmount = isDistanceRequest && iouAmount === 0; - const taxRates = policy?.taxRates; const transactionID = transaction?.transactionID ?? ''; + const customUnitRateID = TransactionUtils.getRateID(transaction) ?? ''; + + useEffect(() => { + if (customUnitRateID || !canUseP2PDistanceRequests) { + return; + } + if (!customUnitRateID) { + const rateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? defaultMileageRate?.customUnitRateID ?? ''; + IOU.setCustomUnitRateID(transactionID, rateID); + } + }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, canUseP2PDistanceRequests, transactionID]); + + const policyCurrency = policy?.outputCurrency ?? PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD; + + const mileageRate = TransactionUtils.isCustomUnitRateIDForP2P(transaction) + ? DistanceRequestUtils.getRateForP2P(policyCurrency) + : mileageRates?.[customUnitRateID] ?? DistanceRequestUtils.getDefaultMileageRate(policy); + + const {unit, rate} = mileageRate ?? {}; + + const prevRate = usePrevious(rate); + const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate); + + const currency = (mileageRate as MileageRate)?.currency ?? policyCurrency; + + const distance = transaction?.routes?.route0?.distance ?? 0; + const taxRates = policy?.taxRates ?? null; // A flag for showing the categories field const shouldShowCategories = isPolicyExpenseChat && (!!iouCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {}))); @@ -255,12 +284,12 @@ function MoneyRequestConfirmationList({ // A flag for showing the billable field const shouldShowBillable = policy?.disabledFields?.defaultBillable === false; const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); - const hasRoute = TransactionUtils.hasRoute(transaction); + const hasRoute = TransactionUtils.hasRoute(transaction, isDistanceRequest); const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense; const formattedAmount = isDistanceRequestWithPendingRoute ? '' : CurrencyUtils.convertToDisplayString( - shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate ?? 0) : iouAmount, + shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0) : iouAmount, isDistanceRequest ? currency : iouCurrencyCode, ); const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode); @@ -329,7 +358,7 @@ function MoneyRequestConfirmationList({ return; } - const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate ?? 0); + const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0); IOU.setMoneyRequestAmount(transactionID, amount, currency ?? ''); }, [shouldCalculateDistanceAmount, distance, rate, unit, transactionID, currency]); @@ -720,13 +749,50 @@ function MoneyRequestConfirmationList({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing disabled={didConfirm} // todo: handle edit for transaction while moving from track expense interactive={!isReadOnly && !isMovingTransactionFromTrackExpense} /> ), - shouldShow: isDistanceRequest, - isSupplementary: true, + shouldShow: isDistanceRequest && !canUseP2PDistanceRequests, + isSupplementary: false, + }, + { + item: ( + <MenuItemWithTopDescription + key={translate('common.distance')} + shouldShowRightIcon={!isReadOnly} + title={DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate)} + description={translate('common.distance')} + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + onPress={() => + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())) + } + disabled={didConfirm} + interactive={!isReadOnly} + /> + ), + shouldShow: isDistanceRequest && canUseP2PDistanceRequests, + isSupplementary: false, + }, + { + item: ( + <MenuItemWithTopDescription + key={translate('common.rate')} + shouldShowRightIcon={Boolean(rate) && !isReadOnly && isPolicyExpenseChat} + title={DistanceRequestUtils.getRateForDisplay(unit, rate, currency, translate, toLocaleDigit, isOffline)} + description={translate('common.rate')} + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + interactive={Boolean(rate) && !isReadOnly && isPolicyExpenseChat} + /> + ), + shouldShow: isDistanceRequest && canUseP2PDistanceRequests, + isSupplementary: false, }, { item: ( @@ -986,11 +1052,18 @@ export default withOnyx<MoneyRequestConfirmationListProps, MoneyRequestConfirmat policyTags: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, }, - mileageRate: { + defaultMileageRate: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, selector: DistanceRequestUtils.getDefaultMileageRate, }, + mileageRates: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + selector: DistanceRequestUtils.getMileageRates, + }, policy: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, }, + lastSelectedDistanceRates: { + key: ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, + }, })(MoneyRequestConfirmationList); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index c5cad0eccdeb..10d7b85afc64 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -12,6 +12,7 @@ import Switch from '@components/Switch'; import Text from '@components/Text'; import ViolationMessages from '@components/ViolationMessages'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import usePermissions from '@hooks/usePermissions'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -21,6 +22,8 @@ import type {ViolationField} from '@hooks/useViolations'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; +import type {MileageRate} from '@libs/DistanceRequestUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import {isTaxTrackingEnabled} from '@libs/PolicyUtils'; @@ -63,6 +66,9 @@ type MoneyRequestViewOnyxPropsWithoutTransaction = { /** The actions from the parent report */ parentReportActions: OnyxEntry<OnyxTypes.ReportActions>; + + /** The rates for the policy */ + rates: Record<string, MileageRate>; }; type MoneyRequestViewPropsWithoutTransaction = MoneyRequestViewOnyxPropsWithoutTransaction & { @@ -89,13 +95,15 @@ function MoneyRequestView({ policy, transactionViolations, shouldShowAnimatedBackground, + rates, }: MoneyRequestViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const {isOffline} = useNetwork(); const {isSmallScreenWidth} = useWindowDimensions(); - const {translate} = useLocalize(); - const {canUseViolations} = usePermissions(); + const {translate, toLocaleDigit} = useLocalize(); + const {canUseViolations, canUseP2PDistanceRequests} = usePermissions(); const parentReportAction = parentReportActions?.[report.parentReportActionID ?? ''] ?? null; const moneyRequestReport = parentReport; const { @@ -170,6 +178,18 @@ function MoneyRequestView({ let amountDescription = `${translate('iou.amount')}`; + const hasRoute = TransactionUtils.hasRoute(transaction, isDistanceRequest); + const rateID = transaction?.comment.customUnit?.customUnitRateID ?? '0'; + + const currency = policy ? policy.outputCurrency : PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD; + + const mileageRate = TransactionUtils.isCustomUnitRateIDForP2P(transaction) ? DistanceRequestUtils.getRateForP2P(currency) : rates[rateID as string] ?? {}; + const {unit, rate} = mileageRate; + + const distance = DistanceRequestUtils.getDistanceFromMerchant(transactionMerchant, unit); + const rateToDisplay = DistanceRequestUtils.getRateForDisplay(unit, rate, currency, translate, toLocaleDigit, isOffline); + const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate); + const saveBillable = useCallback( (newBillable: boolean) => { // If the value hasn't changed, don't request to save changes on the server and just close the modal @@ -255,6 +275,48 @@ function MoneyRequestView({ [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, hasErrors, canUseViolations, hasViolations, translate, getViolationsForField], ); + const distanceRequestFields = canUseP2PDistanceRequests ? ( + <> + <OfflineWithFeedback pendingAction={getPendingFieldAction('waypoints')}> + <MenuItemWithTopDescription + description={translate('common.distance')} + title={distanceToDisplay} + interactive={canEditDistance} + shouldShowRightIcon={canEditDistance} + titleStyle={styles.flex1} + onPress={() => + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) + } + /> + </OfflineWithFeedback> + <OfflineWithFeedback pendingAction={getPendingFieldAction('waypoints')}> + <MenuItemWithTopDescription + description={translate('common.rate')} + title={rateToDisplay} + // TODO: https://github.com/Expensify/App/issues/36987 make it interactive and show right icon when EditRatePage is ready + interactive={false} + shouldShowRightIcon={false} + titleStyle={styles.flex1} + // TODO: https://github.com/Expensify/App/issues/36987 Add route for editing rate + onPress={() => {}} + /> + </OfflineWithFeedback> + </> + ) : ( + <OfflineWithFeedback pendingAction={getPendingFieldAction('waypoints')}> + <MenuItemWithTopDescription + description={translate('common.distance')} + title={transactionMerchant} + interactive={canEditDistance} + shouldShowRightIcon={canEditDistance} + titleStyle={styles.flex1} + onPress={() => + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) + } + /> + </OfflineWithFeedback> + ); + return ( <View style={[StyleUtils.getReportWelcomeContainerStyle(isSmallScreenWidth, true, shouldShowAnimatedBackground)]}> {shouldShowAnimatedBackground && <AnimatedEmptyStateBackground />} @@ -343,20 +405,7 @@ function MoneyRequestView({ /> </OfflineWithFeedback> {isDistanceRequest ? ( - <OfflineWithFeedback pendingAction={getPendingFieldAction('waypoints')}> - <MenuItemWithTopDescription - description={translate('common.distance')} - title={transactionMerchant} - interactive={canEditDistance} - shouldShowRightIcon={canEditDistance} - titleStyle={styles.flex1} - onPress={() => - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID), - ) - } - /> - </OfflineWithFeedback> + distanceRequestFields ) : ( <OfflineWithFeedback pendingAction={getPendingFieldAction('merchant')}> <MenuItemWithTopDescription @@ -424,7 +473,14 @@ function MoneyRequestView({ ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, orderWeight, transaction?.transactionID ?? '', report.reportID), ) } - brickRoadIndicator={getErrorForField('tag', {tagListIndex: index, tagListName: name}) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + brickRoadIndicator={ + getErrorForField('tag', { + tagListIndex: index, + tagListName: name, + }) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined + } error={getErrorForField('tag', {tagListIndex: index, tagListName: name})} /> </OfflineWithFeedback> @@ -451,6 +507,8 @@ function MoneyRequestView({ ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID), ) } + brickRoadIndicator={getErrorForField('tax') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('tax')} /> </OfflineWithFeedback> )} @@ -520,6 +578,10 @@ export default withOnyx<MoneyRequestViewPropsWithoutTransaction, MoneyRequestVie key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, canEvict: false, }, + rates: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, + selector: DistanceRequestUtils.getMileageRates, + }, })( withOnyx<MoneyRequestViewProps, MoneyRequestViewTransactionOnyxProps>({ transaction: { diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index 31cd0aee37da..0138e2f870e1 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -2,7 +2,7 @@ import type {AVPlaybackStatus, VideoFullscreenUpdateEvent} from 'expo-av'; import {ResizeMode, Video, VideoFullscreenUpdate} from 'expo-av'; import type {MutableRefObject} from 'react'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; import type {GestureResponderEvent} from 'react-native'; import {View} from 'react-native'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; @@ -194,6 +194,18 @@ function BaseVideoPlayer({ }); }, [currentVideoPlayerRef, handleFullscreenUpdate, handlePlaybackStatusUpdate]); + // use `useLayoutEffect` instead of `useEffect` because ref is null when unmount in `useEffect` hook + // ref url: https://reactjs.org/blog/2020/08/10/react-v17-rc.html#effect-cleanup-timing + useLayoutEffect( + () => () => { + if (shouldUseSharedVideoElement || videoPlayerRef.current !== currentVideoPlayerRef.current) { + return; + } + currentVideoPlayerRef.current = null; + }, + [currentVideoPlayerRef, shouldUseSharedVideoElement], + ); + useEffect(() => { if (!isUploading || !videoPlayerRef.current) { return; diff --git a/src/languages/en.ts b/src/languages/en.ts index ed2587e5e2c6..87864e7e65f1 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -51,6 +51,7 @@ import type { PayerPaidParams, PayerSettledParams, PaySomeoneParams, + ReimbursementRateParams, RemovedTheRequestParams, RenamedRoomActionParams, ReportArchiveReasonsClosedParams, @@ -315,6 +316,7 @@ export default { member: 'Member', role: 'Role', currency: 'Currency', + rate: 'Rate', emptyLHN: { title: 'Woohoo! All caught up.', subtitleText1: 'Find a chat using the', @@ -628,7 +630,8 @@ export default { canceled: 'Canceled', posted: 'Posted', deleteReceipt: 'Delete receipt', - routePending: 'Route pending...', + routePending: 'Pending...', + defaultRate: 'Default rate', receiptScanning: 'Scan in progress…', receiptMissingDetails: 'Receipt missing details', missingAmount: 'Missing amount', @@ -732,6 +735,7 @@ export default { set: 'set', changed: 'changed', removed: 'removed', + chooseARate: ({unit}: ReimbursementRateParams) => `Select a workspace reimbursement rate per ${unit}`, }, notificationPreferencesPage: { header: 'Notification preferences', @@ -2653,9 +2657,9 @@ export default { body: `It pays to get paid! Submit an expense to a new Expensify account and get $${CONST.REFERRAL_PROGRAM.REVENUE} when they become a customer.`, }, [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: { - buttonText1: 'Pay Someone, ', + buttonText1: 'Pay someone, ', buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, - header: `Pay Someone, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Pay someone, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, body: `You gotta spend money to make money! Pay someone with Expensify and get $${CONST.REFERRAL_PROGRAM.REVENUE} when they become a customer.`, }, [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: { diff --git a/src/languages/es.ts b/src/languages/es.ts index beb654cf0bc4..7572c7f7d28b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -50,6 +50,7 @@ import type { PayerPaidParams, PayerSettledParams, PaySomeoneParams, + ReimbursementRateParams, RemovedTheRequestParams, RenamedRoomActionParams, ReportArchiveReasonsClosedParams, @@ -305,6 +306,7 @@ export default { member: 'Miembro', role: 'Role', currency: 'Divisa', + rate: 'Tarifa', emptyLHN: { title: 'Woohoo! Todo al día.', subtitleText1: 'Encuentra un chat usando el botón', @@ -621,7 +623,8 @@ export default { canceled: 'Canceló', posted: 'Contabilizado', deleteReceipt: 'Eliminar recibo', - routePending: 'Ruta pendiente...', + routePending: 'Pendiente...', + defaultRate: 'Tasa predeterminada', receiptScanning: 'Escaneo en curso…', receiptMissingDetails: 'Recibo con campos vacíos', missingAmount: 'Falta importe', @@ -727,6 +730,7 @@ export default { set: 'estableció', changed: 'cambió', removed: 'eliminó', + chooseARate: ({unit}: ReimbursementRateParams) => `Seleccione una tasa de reembolso del espacio de trabajo por ${unit}`, }, notificationPreferencesPage: { header: 'Preferencias de avisos', diff --git a/src/languages/types.ts b/src/languages/types.ts index 30b7f842db4c..9426e343bbf0 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -1,4 +1,5 @@ import type {ReportAction} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; import type en from './en'; type AddressLineParams = { @@ -299,6 +300,8 @@ type HeldRequestParams = {comment: string}; type DistanceRateOperationsParams = {count: number}; +type ReimbursementRateParams = {unit: Unit}; + export type { AdminCanceledRequestParams, ApprovedAmountParams, @@ -403,4 +406,5 @@ export type { LogSizeParams, HeldRequestParams, PaySomeoneParams, + ReimbursementRateParams, }; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 12a240ae9041..676c4493fe75 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -1,18 +1,55 @@ -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type {RateAndUnit} from '@src/CONST'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {LastSelectedDistanceRates, Report} from '@src/types/onyx'; import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; +import type {EmptyObject} from '@src/types/utils/EmptyObject'; import * as CurrencyUtils from './CurrencyUtils'; import * as PolicyUtils from './PolicyUtils'; +import * as ReportUtils from './ReportUtils'; -type DefaultMileageRate = { +type MileageRate = { customUnitRateID?: string; rate?: number; currency?: string; unit: Unit; + name?: string; }; +const policies: OnyxCollection<Policy> = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (policy, key) => { + if (!policy || !key || !policy.name) { + return; + } + + policies[key] = policy; + }, +}); + +let lastSelectedDistanceRates: OnyxEntry<LastSelectedDistanceRates> = {}; +Onyx.connect({ + key: ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, + callback: (value) => { + lastSelectedDistanceRates = value; + }, +}); + +let allReports: OnyxCollection<Report>; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => (allReports = value), +}); + +const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters +const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter + /** * Retrieves the default mileage rate based on a given policy. * @@ -23,7 +60,7 @@ type DefaultMileageRate = { * @returns [currency] - The currency associated with the rate. * @returns [unit] - The unit of measurement for the distance. */ -function getDefaultMileageRate(policy: OnyxEntry<Policy>): DefaultMileageRate | null { +function getDefaultMileageRate(policy: OnyxEntry<Policy> | EmptyObject): MileageRate | null { if (!policy?.customUnits) { return null; } @@ -33,16 +70,14 @@ function getDefaultMileageRate(policy: OnyxEntry<Policy>): DefaultMileageRate | return null; } - const distanceRate = Object.values(distanceUnit.rates).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - if (!distanceRate) { - return null; - } + const distanceRate = Object.values(distanceUnit.rates).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE) ?? Object.values(distanceUnit.rates)[0]; return { customUnitRateID: distanceRate.customUnitRateID, rate: distanceRate.rate, currency: distanceRate.currency, unit: distanceUnit.attributes.unit, + name: distanceRate.name, }; } @@ -55,9 +90,6 @@ function getDefaultMileageRate(policy: OnyxEntry<Policy>): DefaultMileageRate | * @returns The converted distance in the specified unit. */ function convertDistanceUnit(distanceInMeters: number, unit: Unit): number { - const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters - const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter - switch (unit) { case CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS: return distanceInMeters * METERS_TO_KM; @@ -78,6 +110,37 @@ function getRoundedDistanceInUnits(distanceInMeters: number, unit: Unit): string return convertedDistance.toFixed(2); } +/** + * @param unit Unit that should be used to display the distance + * @param rate Expensable amount allowed per unit + * @param currency The currency associated with the rate + * @param translate Translate function + * @param toLocaleDigit Function to convert to localized digit + * @returns A string that describes the distance traveled and the rate used for expense calculation + */ +function getRateForDisplay( + unit: Unit | undefined, + rate: number | undefined, + currency: string | undefined, + translate: LocaleContextProps['translate'], + toLocaleDigit: LocaleContextProps['toLocaleDigit'], + isOffline?: boolean, +): string { + if (isOffline && !rate) { + return translate('iou.defaultRate'); + } + if (!rate || !currency || !unit) { + return translate('iou.routePending'); + } + + const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer'); + const ratePerUnit = PolicyUtils.getUnitRateValue(toLocaleDigit, {rate}); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const currencySymbol = CurrencyUtils.getCurrencySymbol(currency) || `${currency} `; + + return `${currencySymbol}${ratePerUnit} / ${singularDistanceUnit}`; +} + /** * @param hasRoute Whether the route exists for the distance expense * @param distanceInMeters Distance traveled @@ -86,8 +149,8 @@ function getRoundedDistanceInUnits(distanceInMeters: number, unit: Unit): string * @param translate Translate function * @returns A string that describes the distance traveled */ -function getDistanceForDisplay(hasRoute: boolean, distanceInMeters: number, unit: Unit, rate: number, translate: LocaleContextProps['translate']): string { - if (!hasRoute || !rate) { +function getDistanceForDisplay(hasRoute: boolean, distanceInMeters: number, unit: Unit | undefined, rate: number | undefined, translate: LocaleContextProps['translate']): string { + if (!hasRoute || !rate || !unit) { return translate('iou.routePending'); } @@ -112,8 +175,8 @@ function getDistanceForDisplay(hasRoute: boolean, distanceInMeters: number, unit function getDistanceMerchant( hasRoute: boolean, distanceInMeters: number, - unit: Unit, - rate: number, + unit: Unit | undefined, + rate: number | undefined, currency: string, translate: LocaleContextProps['translate'], toLocaleDigit: LocaleContextProps['toLocaleDigit'], @@ -122,14 +185,50 @@ function getDistanceMerchant( return translate('iou.routePending'); } - const formattedDistance = getDistanceForDisplay(hasRoute, distanceInMeters, unit, rate, translate); - const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer'); - const ratePerUnit = PolicyUtils.getUnitRateValue(toLocaleDigit, {rate}); + const distanceInUnits = getDistanceForDisplay(hasRoute, distanceInMeters, unit, rate, translate); + const ratePerUnit = getRateForDisplay(unit, rate, currency, translate, toLocaleDigit); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const currencySymbol = CurrencyUtils.getCurrencySymbol(currency) || `${currency} `; + return `${distanceInUnits} @ ${ratePerUnit}`; +} + +/** + * Retrieves the mileage rates for given policy. + * + * @param policy - The policy from which to extract the mileage rates. + * + * @returns An array of mileage rates or an empty array if not found. + */ +function getMileageRates(policy: OnyxEntry<Policy>): Record<string, MileageRate> { + const mileageRates: Record<string, MileageRate> = {}; + + if (!policy) { + return mileageRates; + } + + if (!policy?.customUnits) { + return mileageRates; + } + + const distanceUnit = Object.values(policy.customUnits).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + if (!distanceUnit?.rates) { + return mileageRates; + } + + Object.entries(distanceUnit.rates).forEach(([rateID, rate]) => { + mileageRates[rateID] = { + rate: rate.rate, + currency: rate.currency, + unit: distanceUnit.attributes.unit, + name: rate.name, + customUnitRateID: rate.customUnitRateID, + }; + }); + + return mileageRates; +} - return `${formattedDistance} @ ${currencySymbol}${ratePerUnit} / ${singularDistanceUnit}`; +function getRateForP2P(currency: string): RateAndUnit { + return CONST.CURRENCY_TO_DEFAULT_MILEAGE_RATE[currency] ?? CONST.CURRENCY_TO_DEFAULT_MILEAGE_RATE.USD; } /** @@ -146,6 +245,52 @@ function getDistanceRequestAmount(distance: number, unit: Unit, rate: number): n return Math.round(roundedDistance * rate); } -export default {getDefaultMileageRate, getDistanceMerchant, getDistanceRequestAmount}; +/** + * Extracts the distance from a merchant string. + * + * @param merchant - The merchant string containing the distance. + * @returns The distance extracted from the merchant string. + */ +function getDistanceFromMerchant(merchant: string | undefined, unit: Unit): number { + if (!merchant) { + return 0; + } + + const distance = Number(merchant.split(' ')[0]); + if (!distance) { + return 0; + } + // we need to convert the distance back to meters (it's saved in kilometers or miles in merchant) to pass it to getDistanceForDisplay + return unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? distance / METERS_TO_KM : distance / METERS_TO_MILES; +} + +/** + * Returns custom unit rate ID for the distance transaction + */ +function getCustomUnitRateID(reportID: string) { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null; + const policy = PolicyUtils.getPolicy(report?.policyID ?? parentReport?.policyID ?? ''); + + let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; + + if (ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isPolicyExpenseChat(parentReport)) { + customUnitRateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? getDefaultMileageRate(policy)?.customUnitRateID ?? ''; + } + + return customUnitRateID; +} + +export default { + getDefaultMileageRate, + getDistanceMerchant, + getDistanceRequestAmount, + getRateForDisplay, + getMileageRates, + getDistanceForDisplay, + getRateForP2P, + getDistanceFromMerchant, + getCustomUnitRateID, +}; -export type {DefaultMileageRate}; +export type {MileageRate}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index ba40814b9860..8da01424418a 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -81,6 +81,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator<MoneyRequestNa [SCREENS.MONEY_REQUEST.STEP_DATE]: () => require('../../../../pages/iou/request/step/IOURequestStepDate').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: () => require('../../../../pages/iou/request/step/IOURequestStepDescription').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_DISTANCE]: () => require('../../../../pages/iou/request/step/IOURequestStepDistance').default as React.ComponentType, + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE]: () => require('@pages/iou/request/step/IOURequestStepDistanceRate').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_MERCHANT]: () => require('../../../../pages/iou/request/step/IOURequestStepMerchant').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS]: () => require('../../../../pages/iou/request/step/IOURequestStepParticipants').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_SCAN]: () => require('../../../../pages/iou/request/step/IOURequestStepScan').default as React.ComponentType, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 472aa3a1ebad..6a1eb09bcaca 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -569,6 +569,7 @@ const config: LinkingOptions<RootStackParamList>['config'] = { [SCREENS.MONEY_REQUEST.STEP_DATE]: ROUTES.MONEY_REQUEST_STEP_DATE.route, [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.route, [SCREENS.MONEY_REQUEST.STEP_DISTANCE]: ROUTES.MONEY_REQUEST_STEP_DISTANCE.route, + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE]: ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.route, [SCREENS.MONEY_REQUEST.HOLD]: ROUTES.MONEY_REQUEST_HOLD_REASON.route, [SCREENS.MONEY_REQUEST.STEP_MERCHANT]: ROUTES.MONEY_REQUEST_STEP_MERCHANT.route, [SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS]: ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index c0cf571fba1c..f73ddbb46b8c 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -483,6 +483,12 @@ type MoneyRequestNavigatorParamList = { action: IOUAction; currency?: string; }; + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE]: { + iouType: ValueOf<typeof CONST.IOU.TYPE>; + transactionID: string; + backTo: Routes; + reportID: string; + }; [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: { action: IOUAction; iouType: IOUType; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index b4cf4b164a19..b9774949e2d9 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -1,5 +1,6 @@ import Str from 'expensify-common/lib/str'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -15,6 +16,14 @@ import {getPersonalDetailByEmail} from './PersonalDetailsUtils'; type MemberEmailsToAccountIDs = Record<string, number>; +let allPolicies: OnyxCollection<Policy>; + +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (value) => (allPolicies = value), +}); + /** * Filter out the active policies, which will exclude policies with pending deletion * These are policies that we can use to create reports with in NewDot. @@ -311,6 +320,10 @@ function isPolicyFeatureEnabled(policy: OnyxEntry<Policy> | EmptyObject, feature return Boolean(policy?.[featureName]); } +function getPersonalPolicy() { + return Object.values(allPolicies ?? {}).find((policy) => policy?.type === CONST.POLICY.TYPE.PERSONAL); +} + /** * Get the currently selected policy ID stored in the navigation state. */ @@ -318,6 +331,16 @@ function getPolicyIDFromNavigationState() { return getPolicyIDFromState(navigationRef.getRootState() as State<RootStackParamList>); } +/** + * Returns the policy of the report + */ +function getPolicy(policyID: string | undefined): Policy | EmptyObject { + if (!allPolicies || !policyID) { + return {}; + } + return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {}; +} + export { getActivePolicies, hasAccountingConnections, @@ -352,11 +375,13 @@ export { getPathWithoutPolicyID, getPolicyEmployeeListByIdWithoutCurrentUser, goBackFromInvalidPolicy, + getPersonalPolicy, isPolicyFeatureEnabled, hasTaxRateError, getTaxByID, hasPolicyCategoriesError, getPolicyIDFromNavigationState, + getPolicy, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a15e1937dbe2..af55b4ca29be 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -75,7 +75,6 @@ import * as PhoneNumber from './PhoneNumber'; import * as PolicyUtils from './PolicyUtils'; import type {LastVisibleMessage} from './ReportActionsUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; -import shouldAllowRawHTMLMessages from './shouldAllowRawHTMLMessages'; import * as TransactionUtils from './TransactionUtils'; import * as Url from './Url'; import * as UserUtils from './UserUtils'; @@ -3166,7 +3165,7 @@ function getParsedComment(text: string): string { return mentionWithDomain ? `@${mentionWithDomain}` : match; }); - return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(textWithMention, {shouldEscapeText: !shouldAllowRawHTMLMessages()}) : lodashEscape(text); + return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(textWithMention) : lodashEscape(text); } function getReportDescriptionText(report: Report): string { diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index a5b85b87e37e..e555c49e1007 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -515,8 +515,8 @@ function hasMissingSmartscanFields(transaction: OnyxEntry<Transaction>): boolean /** * Check if the transaction has a defined route */ -function hasRoute(transaction: OnyxEntry<Transaction>): boolean { - return !!transaction?.routes?.route0?.geometry?.coordinates; +function hasRoute(transaction: OnyxEntry<Transaction>, isDistanceRequestType: boolean): boolean { + return !!transaction?.routes?.route0?.geometry?.coordinates || (isDistanceRequestType && !!transaction?.comment?.customUnit?.quantity); } function getAllReportTransactions(reportID?: string): Transaction[] { @@ -628,6 +628,20 @@ function getEnabledTaxRateCount(options: TaxRates) { return Object.values(options).filter((option: TaxRate) => !option.isDisabled).length; } +/** + * Check if the customUnitRateID has a value default for P2P distance requests + */ +function isCustomUnitRateIDForP2P(transaction: OnyxEntry<Transaction>): boolean { + return transaction?.comment?.customUnit?.customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID; +} + +/** + * Get rate ID from the transaction object + */ +function getRateID(transaction: OnyxEntry<Transaction>): string | undefined { + return transaction?.comment?.customUnit?.customUnitRateID?.toString(); +} + /** * Gets the default tax name */ @@ -700,6 +714,8 @@ export { waypointHasValidAddress, getRecentTransactions, hasViolation, + isCustomUnitRateIDForP2P, + getRateID, }; export type {TransactionChanges}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 5217be1686c2..ddcd47b52e2c 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -234,14 +234,6 @@ Onyx.connect({ }, }); -let lastSelectedDistanceRates: OnyxEntry<OnyxTypes.LastSelectedDistanceRates> = {}; -Onyx.connect({ - key: ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, - callback: (value) => { - lastSelectedDistanceRates = value; - }, -}); - let quickAction: OnyxEntry<OnyxTypes.QuickAction> = {}; Onyx.connect({ key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, @@ -250,13 +242,6 @@ Onyx.connect({ }, }); -let allPolicies: OnyxCollection<OnyxTypes.Policy>; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.POLICY, - waitForCollectionCallback: true, - callback: (value) => (allPolicies = value), -}); - let allReportActions: OnyxCollection<OnyxTypes.ReportActions>; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, @@ -269,16 +254,6 @@ Onyx.connect({ }, }); -/** - * Returns the policy of the report - */ -function getPolicy(policyID: string | undefined): OnyxTypes.Policy | EmptyObject { - if (!allPolicies || !policyID) { - return {}; - } - return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {}; -} - /** * Find the report preview action from given chat report and iou report */ @@ -315,12 +290,10 @@ function initMoneyRequest(reportID: string, policy: OnyxEntry<OnyxTypes.Policy>, waypoint0: {}, waypoint1: {}, }; - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null; - let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; - if (ReportUtils.isPolicyExpenseChat(report)) { - customUnitRateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? DistanceRequestUtils.getDefaultMileageRate(policy)?.customUnitRateID ?? ''; + if (!isFromGlobalCreate) { + const customUnitRateID = DistanceRequestUtils.getCustomUnitRateID(reportID); + comment.customUnit = {customUnitRateID}; } - comment.customUnit = {customUnitRateID}; } // Store the transaction in Onyx and mark it as not saved so it can be cleaned up later @@ -426,6 +399,19 @@ function setMoneyRequestReceipt(transactionID: string, source: string, filename: }); } +/** + * Set custom unit rateID for the transaction draft + */ +function setCustomUnitRateID(transactionID: string, customUnitRateID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {customUnit: {customUnitRateID}}}); +} + +/** Update transaction distance rate */ +function updateDistanceRequestRate(transactionID: string, rateID: string, policyID: string) { + Onyx.merge(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, {[policyID]: rateID}); + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {customUnit: {customUnitRateID: rateID}}}); +} + /** Reset expense info from the store with its initial value */ function resetMoneyRequestInfo(id = '') { // Disabling this line since currentDate can be an empty string @@ -558,7 +544,10 @@ function buildOnyxDataForMoneyRequest( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, - value: transactionThreadReport, + value: { + ...transactionThreadReport, + pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + }, }, // Remove the temporary transaction used during the creation flow { @@ -746,6 +735,7 @@ function buildOnyxDataForMoneyRequest( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, value: { + pendingFields: null, errorFields: existingTransactionThreadReport ? null : { @@ -911,7 +901,10 @@ function buildOnyxDataForTrackExpense( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, - value: transactionThreadReport, + value: { + ...transactionThreadReport, + pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + }, }, // Remove the temporary transaction used during the creation flow { @@ -1082,6 +1075,7 @@ function buildOnyxDataForTrackExpense( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, value: { + pendingFields: null, errorFields: existingTransactionThreadReport ? null : { @@ -1701,6 +1695,7 @@ function createDistanceRequest( policy?: OnyxEntry<OnyxTypes.Policy>, policyTagList?: OnyxEntry<OnyxTypes.PolicyTagList>, policyCategories?: OnyxEntry<OnyxTypes.PolicyCategories>, + customUnitRateID?: string, ) { // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); @@ -1763,6 +1758,7 @@ function createDistanceRequest( transactionThreadReportID, createdReportActionIDForThread, payerEmail, + customUnitRateID, }; API.write(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, parameters, onyxData); @@ -5331,7 +5327,7 @@ function hasIOUToApproveOrPay(chatReport: OnyxEntry<OnyxTypes.Report> | EmptyObj return Object.values(chatReportActions).some((action) => { const iouReport = ReportUtils.getReport(action.childReportID ?? ''); - const policy = getPolicy(iouReport?.policyID); + const policy = PolicyUtils.getPolicy(iouReport?.policyID); const shouldShowSettlementButton = canIOUBePaid(iouReport, chatReport, policy) || canApproveIOU(iouReport, chatReport, policy); return action.childReportID?.toString() !== excludedIOUReportID && action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && shouldShowSettlementButton; }); @@ -5473,7 +5469,7 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject, full function submitReport(expenseReport: OnyxTypes.Report) { const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null; const parentReport = ReportUtils.getReport(expenseReport.parentReportID); - const policy = getPolicy(expenseReport.policyID); + const policy = PolicyUtils.getPolicy(expenseReport.policyID); const isCurrentUserManager = currentUserPersonalDetails.accountID === expenseReport.managerID; const isSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); const adminAccountID = policy.role === CONST.POLICY.ROLE.ADMIN ? currentUserPersonalDetails.accountID : undefined; @@ -5597,7 +5593,7 @@ function submitReport(expenseReport: OnyxTypes.Report) { function cancelPayment(expenseReport: OnyxTypes.Report, chatReport: OnyxTypes.Report) { const optimisticReportAction = ReportUtils.buildOptimisticCancelPaymentReportAction(expenseReport.reportID, -(expenseReport.total ?? 0), expenseReport.currency ?? ''); - const policy = getPolicy(chatReport.policyID); + const policy = PolicyUtils.getPolicy(chatReport.policyID); const isFree = policy && policy.type === CONST.POLICY.TYPE.FREE; const approvalMode = policy.approvalMode ?? CONST.POLICY.APPROVAL_MODE.BASIC; let stateNum: ValueOf<typeof CONST.REPORT.STATE_NUM> = CONST.REPORT.STATE_NUM.SUBMITTED; @@ -6000,6 +5996,7 @@ export { savePreferredPaymentMethod, sendMoneyElsewhere, sendMoneyWithWallet, + setCustomUnitRateID, setDraftSplitTransaction, setMoneyRequestAmount, setMoneyRequestBillable, @@ -6027,6 +6024,7 @@ export { submitReport, trackExpense, unholdRequest, + updateDistanceRequestRate, updateMoneyRequestAmountAndCurrency, updateMoneyRequestBillable, updateMoneyRequestCategory, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index a4afff17d972..411cee718062 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -82,6 +82,7 @@ import type { PersonalDetails, PersonalDetailsList, PolicyReportField, + QuickAction, RecentlyUsedReportFields, ReportActionReactions, ReportMetadata, @@ -219,6 +220,12 @@ Onyx.connect({ callback: (val) => (allRecentlyUsedReportFields = val), }); +let quickAction: OnyxEntry<QuickAction> = {}; +Onyx.connect({ + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + callback: (val) => (quickAction = val), +}); + function clearGroupChat() { Onyx.set(ONYXKEYS.NEW_GROUP_CHAT_DRAFT, null); } @@ -2421,7 +2428,7 @@ function navigateToMostRecentReport(currentReport: OnyxEntry<Report>) { if (!isChatThread) { Navigation.goBack(); } - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID ?? '')); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID ?? ''), CONST.NAVIGATION.TYPE.FORCED_UP); } else { const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins([CONST.EMAIL.CONCIERGE]); const chat = ReportUtils.getChatByParticipants(participantAccountIDs); @@ -2430,7 +2437,7 @@ function navigateToMostRecentReport(currentReport: OnyxEntry<Report>) { if (!isChatThread) { Navigation.goBack(); } - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(chat?.reportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(chat?.reportID), CONST.NAVIGATION.TYPE.FORCED_UP); } } } @@ -2449,6 +2456,15 @@ function leaveGroupChat(reportID: string) { value: null, }, ]; + // Clean up any quick actions for the report we're leaving from + if (quickAction?.chatReportID?.toString() === reportID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: null, + }); + } + navigateToMostRecentReport(report); API.write(WRITE_COMMANDS.LEAVE_GROUP_CHAT, {reportID}, {optimisticData}); } diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index d9893d93d2f6..6517a7a28642 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -242,7 +242,7 @@ function base64ToFile(base64: string, filename: string): File { } function validateImageForCorruption(file: FileObject): Promise<void> { - if (!Str.isImage(file.name ?? '')) { + if (!Str.isImage(file.name ?? '') || !file.uri) { return Promise.resolve(); } return new Promise((resolve, reject) => { diff --git a/src/libs/shouldAllowRawHTMLMessages/index.native.ts b/src/libs/shouldAllowRawHTMLMessages/index.native.ts deleted file mode 100644 index db886f7f6fe8..000000000000 --- a/src/libs/shouldAllowRawHTMLMessages/index.native.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function () { - return false; -} diff --git a/src/libs/shouldAllowRawHTMLMessages/index.ts b/src/libs/shouldAllowRawHTMLMessages/index.ts deleted file mode 100644 index 577dc3055441..000000000000 --- a/src/libs/shouldAllowRawHTMLMessages/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -window.shouldAllowRawHTMLMessages = false; - -export default function () { - return window.shouldAllowRawHTMLMessages; -} diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index bc55511cee71..3a7f4ad6f28d 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -82,6 +82,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD const chatRoomSubtitle = useMemo(() => ReportUtils.getChatRoomSubtitle(report), [report, policy]); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(report); const isGroupChat = useMemo(() => ReportUtils.isGroupChat(report), [report]); + const isThread = useMemo(() => ReportUtils.isThread(report), [report]); const participants = useMemo(() => { if (isGroupChat) { return ReportUtils.getParticipantAccountIDs(report.reportID ?? ''); @@ -227,27 +228,28 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD /> ) : null; - const renderAvatar = isGroupChat ? ( - <AvatarWithImagePicker - source={icons[0].source} - isUsingDefaultAvatar={!report.avatarUrl} - size={CONST.AVATAR_SIZE.XLARGE} - avatarStyle={styles.avatarXLarge} - shouldDisableViewPhoto - onImageRemoved={() => { - // Calling this without a file will remove the avatar - Report.updateGroupChatAvatar(report.reportID ?? ''); - }} - onImageSelected={(file) => Report.updateGroupChatAvatar(report.reportID ?? '', file)} - editIcon={Expensicons.Camera} - editIconStyle={styles.smallEditIconAccount} - /> - ) : ( - <RoomHeaderAvatars - icons={icons} - reportID={report?.reportID} - /> - ); + const renderAvatar = + isGroupChat && !isThread ? ( + <AvatarWithImagePicker + source={icons[0].source} + isUsingDefaultAvatar={!report.avatarUrl} + size={CONST.AVATAR_SIZE.XLARGE} + avatarStyle={styles.avatarXLarge} + shouldDisableViewPhoto + onImageRemoved={() => { + // Calling this without a file will remove the avatar + Report.updateGroupChatAvatar(report.reportID ?? ''); + }} + onImageSelected={(file) => Report.updateGroupChatAvatar(report.reportID ?? '', file)} + editIcon={Expensicons.Camera} + editIconStyle={styles.smallEditIconAccount} + /> + ) : ( + <RoomHeaderAvatars + icons={icons} + reportID={report?.reportID} + /> + ); const reportName = ReportUtils.isDeprecatedGroupDM(report) || ReportUtils.isGroupChat(report) diff --git a/src/pages/WorkspaceSwitcherPage/index.tsx b/src/pages/WorkspaceSwitcherPage/index.tsx index 0a5492536d56..f65ce10ce649 100644 --- a/src/pages/WorkspaceSwitcherPage/index.tsx +++ b/src/pages/WorkspaceSwitcherPage/index.tsx @@ -170,26 +170,22 @@ function WorkspaceSwitcherPage() { testID={WorkspaceSwitcherPage.displayName} includeSafeAreaPaddingBottom={false} > - {({didScreenTransitionEnd}) => ( - <> - <HeaderWithBackButton - title={translate('workspace.switcher.headerTitle')} - onBackButtonPress={Navigation.goBack} - /> - <SelectionList<WorkspaceListItem> - ListItem={UserListItem} - sections={didScreenTransitionEnd ? sections : CONST.EMPTY_ARRAY} - onSelectRow={selectPolicy} - textInputLabel={usersWorkspaces.length >= CONST.WORKSPACE_SWITCHER.MINIMUM_WORKSPACES_TO_SHOW_SEARCH ? translate('common.search') : undefined} - textInputValue={searchTerm} - onChangeText={setSearchTerm} - headerMessage={headerMessage} - listFooterContent={shouldShowCreateWorkspace ? WorkspaceCardCreateAWorkspaceInstance : null} - initiallyFocusedOptionKey={activeWorkspaceID ?? CONST.WORKSPACE_SWITCHER.NAME} - showLoadingPlaceholder - /> - </> - )} + <HeaderWithBackButton + title={translate('workspace.switcher.headerTitle')} + onBackButtonPress={Navigation.goBack} + /> + <SelectionList<WorkspaceListItem> + ListItem={UserListItem} + sections={sections} + onSelectRow={selectPolicy} + textInputLabel={usersWorkspaces.length >= CONST.WORKSPACE_SWITCHER.MINIMUM_WORKSPACES_TO_SHOW_SEARCH ? translate('common.search') : undefined} + textInputValue={searchTerm} + onChangeText={setSearchTerm} + headerMessage={headerMessage} + listFooterContent={shouldShowCreateWorkspace ? WorkspaceCardCreateAWorkspaceInstance : null} + initiallyFocusedOptionKey={activeWorkspaceID ?? CONST.WORKSPACE_SWITCHER.NAME} + showLoadingPlaceholder + /> </ScreenWrapper> ); } diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index f9f069a2172a..9881b207592b 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -270,6 +270,8 @@ function FloatingActionButtonAndPopover( showCreateMenu(); } }; + // eslint-disable-next-line react-hooks/exhaustive-deps + const selfDMReportID = useMemo(() => ReportUtils.findSelfDMReportID(), [isLoading]); return ( <View style={styles.flexGrow1}> @@ -285,7 +287,7 @@ function FloatingActionButtonAndPopover( text: translate('sidebarScreen.fabNewChat'), onSelected: () => interceptAnonymousUser(Report.startNewChat), }, - ...(canUseTrackExpense + ...(canUseTrackExpense && selfDMReportID ? [ { icon: Expensicons.DocumentPlus, diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js index efd98d935a01..a184c1ac8fb7 100644 --- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js +++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js @@ -196,7 +196,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({participants, onF (option) => { onParticipantsAdded([ { - ...lodashPick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText'), + ...lodashPick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText', 'policyID'), selected: true, iouType, }, diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 2cc59bf0af14..f4abc90bd315 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -39,8 +39,8 @@ type IOURequestStepAmountOnyxProps = { /** Whether the confirmation step should be skipped */ skipConfirmation: OnyxEntry<boolean>; - /** The draft transaction object being modified in Onyx */ - draftTransaction: OnyxEntry<OnyxTypes.Transaction>; + /** The backup transaction object being modified in Onyx */ + backupTransaction: OnyxEntry<OnyxTypes.Transaction>; /** Personal details of all users */ personalDetails: OnyxEntry<OnyxTypes.PersonalDetailsList>; @@ -66,7 +66,7 @@ function IOURequestStepAmount({ personalDetails, currentUserPersonalDetails, splitDraftTransaction, - draftTransaction, + backupTransaction, skipConfirmation, }: IOURequestStepAmountProps) { const {translate} = useLocalize(); @@ -79,7 +79,7 @@ function IOURequestStepAmount({ const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; const isEditingSplitBill = isEditing && isSplitBill; const {amount: transactionAmount} = ReportUtils.getTransactionDetails(isEditingSplitBill && !isEmptyObject(splitDraftTransaction) ? splitDraftTransaction : transaction) ?? {amount: 0}; - const {currency: originalCurrency} = ReportUtils.getTransactionDetails(isEditing ? draftTransaction : transaction) ?? {currency: CONST.CURRENCY.USD}; + const {currency: originalCurrency} = ReportUtils.getTransactionDetails(isEditing ? backupTransaction : transaction) ?? {currency: CONST.CURRENCY.USD}; const currency = CurrencyUtils.isValidCurrencyCode(selectedCurrency) ? selectedCurrency : originalCurrency; // For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace request, as @@ -266,10 +266,10 @@ const IOURequestStepAmountWithOnyx = withOnyx<IOURequestStepAmountProps, IOURequ return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`; }, }, - draftTransaction: { + backupTransaction: { key: ({route}) => { const transactionID = route.params.transactionID ?? 0; - return `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`; + return `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`; }, }, skipConfirmation: { diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 057b3e027243..b0d171537fe2 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -254,7 +254,7 @@ function IOURequestStepConfirmation({ ); const createDistanceRequest = useCallback( - (selectedParticipants: Participant[], trimmedComment: string) => { + (selectedParticipants: Participant[], trimmedComment: string, customUnitRateID: string) => { if (!transaction) { return; } @@ -273,6 +273,7 @@ function IOURequestStepConfirmation({ policy, policyTags, policyCategories, + customUnitRateID, ); }, [policy, policyCategories, policyTags, report, transaction], @@ -419,7 +420,8 @@ function IOURequestStepConfirmation({ } if (requestType === CONST.IOU.REQUEST_TYPE.DISTANCE && !IOUUtils.isMovingTransactionFromTrackExpense(action)) { - createDistanceRequest(selectedParticipants, trimmedComment); + const customUnitRateID = TransactionUtils.getRateID(transaction) ?? ''; + createDistanceRequest(selectedParticipants, trimmedComment, customUnitRateID); return; } diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 0602c2184365..c0c251003461 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -85,7 +85,7 @@ function IOURequestStepDistance({ const isLoadingRoute = transaction?.comment?.isLoading ?? false; const isLoading = transaction?.isLoading ?? false; const hasRouteError = !!transaction?.errorFields?.route; - const hasRoute = TransactionUtils.hasRoute(transaction); + const hasRoute = TransactionUtils.hasRoute(transaction, true); const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); const previousValidatedWaypoints = usePrevious(validatedWaypoints); const haveValidatedWaypointsChanged = !isEqual(previousValidatedWaypoints, validatedWaypoints); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx new file mode 100644 index 000000000000..160d4903a512 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as IOU from '@libs/actions/IOU'; +import type {MileageRate} from '@libs/DistanceRequestUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {Policy, Transaction} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; +import StepScreenWrapper from './StepScreenWrapper'; +import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; +import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; + +type IOURequestStepDistanceRateOnyxProps = { + /** Policy details */ + policy: OnyxEntry<Policy>; + + /** Mileage rates */ + rates: Record<string, MileageRate>; +}; + +type IOURequestStepDistanceRateProps = IOURequestStepDistanceRateOnyxProps & + WithWritableReportOrNotFoundProps<typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE> & { + /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ + transaction: OnyxEntry<Transaction>; + }; + +function IOURequestStepDistanceRate({ + policy, + route: { + params: {backTo, transactionID}, + }, + transaction, + rates, +}: IOURequestStepDistanceRateProps) { + const styles = useThemeStyles(); + const {translate, toLocaleDigit} = useLocalize(); + + const lastSelectedRateID = TransactionUtils.getRateID(transaction) ?? ''; + + const navigateBack = () => { + Navigation.goBack(backTo); + }; + + const sections = Object.values(rates).map((rate) => { + const rateForDisplay = DistanceRequestUtils.getRateForDisplay(rate.unit, rate.rate, rate.currency, translate, toLocaleDigit); + + return { + text: rate.name ?? rateForDisplay, + alternateText: rate.name ? rateForDisplay : '', + keyForList: rate.customUnitRateID, + value: rate.customUnitRateID, + isSelected: lastSelectedRateID ? lastSelectedRateID === rate.customUnitRateID : Boolean(rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE), + }; + }); + + const unit = (Object.values(rates)[0]?.unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer')) as Unit; + + const initiallyFocusedOption = rates[lastSelectedRateID]?.name ?? CONST.CUSTOM_UNITS.DEFAULT_RATE; + + function selectDistanceRate(customUnitRateID: string) { + IOU.updateDistanceRequestRate(transactionID, customUnitRateID, policy?.id ?? ''); + navigateBack(); + } + + return ( + <StepScreenWrapper + headerTitle={translate('common.rate')} + onBackButtonPress={navigateBack} + shouldShowWrapper={Boolean(backTo)} + testID="rate" + > + <Text style={[styles.mh5, styles.mv4]}>{translate('iou.chooseARate', {unit})}</Text> + + <SelectionList + sections={[{data: sections}]} + ListItem={RadioListItem} + onSelectRow={({value}) => selectDistanceRate(value ?? '')} + initiallyFocusedOptionKey={initiallyFocusedOption} + /> + </StepScreenWrapper> + ); +} + +IOURequestStepDistanceRate.displayName = 'IOURequestStepDistanceRate'; + +const IOURequestStepDistanceRateWithOnyx = withOnyx<IOURequestStepDistanceRateProps, IOURequestStepDistanceRateOnyxProps>({ + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, + }, + rates: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '0'}`, + selector: DistanceRequestUtils.getMileageRates, + }, +})(IOURequestStepDistanceRate); + +// eslint-disable-next-line rulesdir/no-negated-variables +const IOURequestStepDistanceRateWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepDistanceRateWithOnyx); +// eslint-disable-next-line rulesdir/no-negated-variables +const IOURequestStepDistanceRateWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepDistanceRateWithWritableReportOrNotFound); + +export default IOURequestStepDistanceRateWithFullTransactionOrNotFound; diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 28126c71faaa..d202b02713b3 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -1,6 +1,7 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as TransactionUtils from '@libs/TransactionUtils'; @@ -90,6 +91,9 @@ function IOURequestStepParticipants({ } IOU.setMoneyRequestParticipants_temporaryForRefactor(transactionID, val); + const rateID = DistanceRequestUtils.getCustomUnitRateID(val[0]?.reportID ?? ''); + IOU.setCustomUnitRateID(transactionID, rateID); + numberOfParticipants.current = val.length; // When multiple participants are selected, the reportID is generated at the end of the confirmation step. diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 8bca59b11580..93ad8bb87c14 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -541,6 +541,7 @@ function IOURequestStepScan({ onDrop={(e) => { const file = e?.dataTransfer?.files[0]; if (file) { + file.uri = URL.createObjectURL(file); setReceiptAndNavigate(file); } }} diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index a225e3a1ade0..e29ee52f32a7 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -28,6 +28,7 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS | typeof SCREENS.MONEY_REQUEST.STEP_MERCHANT | typeof SCREENS.MONEY_REQUEST.STEP_TAG + | typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE | typeof SCREENS.MONEY_REQUEST.STEP_CONFIRMATION | typeof SCREENS.MONEY_REQUEST.STEP_CATEGORY | typeof SCREENS.MONEY_REQUEST.STEP_TAX_RATE diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx index af182b51d9d6..ece519d87a1b 100644 --- a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx +++ b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx @@ -21,6 +21,7 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_WAYPOINT | typeof SCREENS.MONEY_REQUEST.STEP_DESCRIPTION | typeof SCREENS.MONEY_REQUEST.STEP_CATEGORY + | typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE | typeof SCREENS.MONEY_REQUEST.STEP_CONFIRMATION | typeof SCREENS.MONEY_REQUEST.STEP_TAX_RATE | typeof SCREENS.MONEY_REQUEST.STEP_AMOUNT diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx index 0310104590e5..0a9c8cd71894 100644 --- a/src/pages/workspace/WorkspaceInvitePage.tsx +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -1,5 +1,5 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {SectionListData} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -60,6 +60,8 @@ function WorkspaceInvitePage({route, betas, invitedEmailsToAccountIDsDraft, poli const [personalDetails, setPersonalDetails] = useState<OptionData[]>([]); const [usersToInvite, setUsersToInvite] = useState<OptionData[]>([]); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); + const firstRenderRef = useRef(true); + const openWorkspaceInvitePage = () => { const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList); Policy.openWorkspaceInvitePage(route.params.policyID, Object.keys(policyMemberEmailsToAccountIDs)); @@ -102,12 +104,16 @@ function WorkspaceInvitePage({route, betas, invitedEmailsToAccountIDsDraft, poli }); const newSelectedOptions: MemberForList[] = []; - Object.keys(invitedEmailsToAccountIDsDraft ?? {}).forEach((login) => { - if (!(login in detailsMap)) { - return; - } - newSelectedOptions.push({...detailsMap[login], isSelected: true}); - }); + if (firstRenderRef.current) { + // We only want to add the saved selected user on first render + firstRenderRef.current = false; + Object.keys(invitedEmailsToAccountIDsDraft ?? {}).forEach((login) => { + if (!(login in detailsMap)) { + return; + } + newSelectedOptions.push({...detailsMap[login], isSelected: true}); + }); + } selectedOptions.forEach((option) => { newSelectedOptions.push(option.login && option.login in detailsMap ? {...detailsMap[option.login], isSelected: true} : option); }); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 911f1ea5f281..03446e813949 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -30,5 +30,4 @@ declare module '*.lottie' { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { setSupportToken: (token: string, email: string, accountID: number) => void; - shouldAllowRawHTMLMessages: boolean; }