diff --git a/.eslintrc.js b/.eslintrc.js index 56a5236a7899..4df9493b2e8c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -260,13 +260,7 @@ module.exports = { 'no-restricted-imports': [ 'error', { - paths: [ - ...restrictedImportPaths, - { - name: 'underscore', - message: 'Please use the corresponding method from lodash instead', - }, - ], + paths: restrictedImportPaths, patterns: restrictedImportPatterns, }, ], diff --git a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts index 2544fe05cddd..57a941105f90 100644 --- a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts +++ b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts @@ -14,6 +14,7 @@ const run = () => { // Initialize string to store Graphite metrics let graphiteString = ''; + let timestamp: number; // Iterate over each entry regressionEntries.forEach((entry) => { @@ -26,7 +27,9 @@ const run = () => { const current = JSON.parse(entry); // Extract timestamp, Graphite accepts timestamp in seconds - const timestamp = current.metadata?.creationDate ? Math.floor(new Date(current.metadata.creationDate).getTime() / 1000) : ''; + if (current.metadata?.creationDate) { + timestamp = Math.floor(new Date(current.metadata.creationDate).getTime() / 1000); + } if (current.name && current.meanDuration && current.meanCount && timestamp) { const formattedName = current.name.split(' ').join('-'); diff --git a/.github/actions/javascript/getGraphiteString/index.js b/.github/actions/javascript/getGraphiteString/index.js index 7f512d575a23..0e042924d92a 100644 --- a/.github/actions/javascript/getGraphiteString/index.js +++ b/.github/actions/javascript/getGraphiteString/index.js @@ -2735,6 +2735,7 @@ const run = () => { const regressionEntries = regressionFile.split('\n'); // Initialize string to store Graphite metrics let graphiteString = ''; + let timestamp; // Iterate over each entry regressionEntries.forEach((entry) => { // Skip empty lines @@ -2744,7 +2745,9 @@ const run = () => { try { const current = JSON.parse(entry); // Extract timestamp, Graphite accepts timestamp in seconds - const timestamp = current.metadata?.creationDate ? Math.floor(new Date(current.metadata.creationDate).getTime() / 1000) : ''; + if (current.metadata?.creationDate) { + timestamp = Math.floor(new Date(current.metadata.creationDate).getTime() / 1000); + } if (current.name && current.meanDuration && current.meanCount && timestamp) { const formattedName = current.name.split(' ').join('-'); const renderDurationString = `${GRAPHITE_PATH}.${formattedName}.renderDuration ${current.meanDuration} ${timestamp}`; 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 = {}; +const visited: Map = new Map(); +const backEdges: Map = new Map(); + +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..cda33d39102e 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -26,6 +26,8 @@ concurrency: jobs: build: + env: + IS_PR_FROM_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest steps: - name: Checkout @@ -36,8 +38,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 @@ -49,7 +51,7 @@ jobs: - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca id: deploy - if: github.event_name != 'pull_request' || (github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork) + if: env.IS_PR_FROM_FORK != 'true' with: apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -57,16 +59,18 @@ jobs: directory: ./docs/_site - name: Setup Cloudflare CLI + if: env.IS_PR_FROM_FORK != 'true' run: pip3 install cloudflare==2.19.0 - name: Purge Cloudflare cache + if: env.IS_PR_FROM_FORK != 'true' run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["help.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache env: CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} - name: Leave a comment on the PR uses: actions-cool/maintain-one-comment@de04bd2a3750d86b324829a3ff34d47e48e16f4b - if: ${{ github.event_name == 'pull_request' }} + if: ${{ github.event_name == 'pull_request' && env.IS_PR_FROM_FORK != 'true' }} with: token: ${{ secrets.OS_BOTIFY_TOKEN }} body: ${{ format('A preview of your ExpensifyHelp changes have been deployed to {0} ⚡️', steps.deploy.outputs.alias) }} diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index d3aa084b1fcf..10723d5efa04 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -107,7 +107,7 @@ jobs: if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }} id: getMergeCommitShaIfUnmergedPR run: | - git merge --allow-unrelated-histories --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} + git merge --allow-unrelated-histories -X ours --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} git checkout ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index f20939f9df0a..88d4d24a5723 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -30,7 +30,7 @@ jobs: # - git diff is used to see the files that were added on this branch # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main # - wc counts the words in the result of the intersection - count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'web/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) + count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) if [ "$count_new_js" -gt "0" ]; then echo "ERROR: Found new JavaScript files in the project; use TypeScript instead." exit 1 diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association index a2c7365f7de8..394e45f8d9ae 100644 --- a/.well-known/apple-app-site-association +++ b/.well-known/apple-app-site-association @@ -52,6 +52,10 @@ "/": "/split/*", "comment": "Split Expense" }, + { + "/": "/submit/*", + "comment": "Submit Expense" + }, { "/": "/request/*", "comment": "Submit Expense" @@ -76,6 +80,10 @@ "/": "/search/*", "comment": "Search" }, + { + "/": "/pay/*", + "comment": "Pay someone" + }, { "/": "/send/*", "comment": "Pay someone" diff --git a/README.md b/README.md index 026a63606db0..3a8aabcdf1be 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ If you're using another operating system, you will need to ensure `mkcert` is in For an M1 Mac, read this [SO](https://stackoverflow.com/questions/64901180/how-to-run-cocoapods-on-apple-silicon-m1) for installing cocoapods. * If you haven't already, install Xcode tools and make sure to install the optional "iOS Platform" package as well. This installation may take awhile. + * After installation, check in System Settings that there's no update for Xcode. Otherwise, you may encounter issues later that don't explain that you solve them by updating Xcode. * Install project gems, including cocoapods, using bundler to ensure everyone uses the same versions. In the project root, run: `bundle install` * If you get the error `Could not find 'bundler'`, install the bundler gem first: `gem install bundler` and try again. * If you are using MacOS and get the error `Gem::FilePermissionError` when trying to install the bundler gem, you're likely using system Ruby, which requires administrator permission to modify. To get around this, install another version of Ruby with a version manager like [rbenv](https://github.com/rbenv/rbenv#installation). @@ -82,6 +83,16 @@ If you want to run the app on an actual physical iOS device, please follow the i ## Running the MacOS desktop app 🖥 * To run the **Development app**, run: `npm run desktop`, this will start a new Electron process running on your MacOS desktop in the `dist/Mac` folder. +## Receiving Notifications +To receive notifications on development build of the app while hitting the Staging or Production API, you need to use the production airship config. +### Android +1. Copy the [production config](https://github.com/Expensify/App/blob/d7c1256f952c0020344d809ee7299b49a4c70db2/android/app/src/main/assets/airshipconfig.properties#L1-L7) to the [development config](https://github.com/Expensify/App/blob/d7c1256f952c0020344d809ee7299b49a4c70db2/android/app/src/development/assets/airshipconfig.properties#L1-L8). +2. Rebuild the app. + +### iOS +1. Replace the [development key and secret](https://github.com/Expensify/App/blob/d7c1256f952c0020344d809ee7299b49a4c70db2/ios/AirshipConfig.plist#L7-L10) with the [production values](https://github.com/Expensify/App/blob/d7c1256f952c0020344d809ee7299b49a4c70db2/ios/AirshipConfig.plist#L11-L14). +2. Rebuild the app. + ## Troubleshooting 1. If you are having issues with **_Getting Started_**, please reference [React Native's Documentation](https://reactnative.dev/docs/environment-setup) 2. If you are running into CORS errors like (in the browser dev console) diff --git a/android/app/build.gradle b/android/app/build.gradle index 0db4b032ec9d..998a43a8c3ff 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -2,12 +2,20 @@ apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" apply plugin: "com.google.firebase.firebase-perf" +apply plugin: "fullstory" apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle" /** * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. */ + +/* Fullstory settings */ +fullstory { + org 'o-1WN56P-na1' + enabledVariants 'all' +} + react { /* Folders */ // The root of your project, i.e. where "package.json" lives. Default is '..' @@ -98,8 +106,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001046300 - versionName "1.4.63-0" + versionCode 1001046500 + versionName "1.4.65-0" // 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" @@ -162,7 +170,7 @@ android { signingConfig null // buildTypes take precedence over productFlavors when it comes to the signing configuration, // thus we need to manually set the signing config, so that the e2e uses the debug config again. - // In other words, the signingConfig setting above will be ignored when we build the flavor in release mode. + // In other words, the signingConfig setting above will be ignored when we build the flavor in release mode. productFlavors.all { flavor -> // All release builds should be signed with the release config ... flavor.signingConfig signingConfigs.release diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 84364f2ef7ff..520602a28a02 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -62,6 +62,7 @@ + @@ -70,6 +71,7 @@ + @@ -81,6 +83,7 @@ + @@ -89,6 +92,7 @@ + diff --git a/android/build.gradle b/android/build.gradle index 10600480d8bb..7ecd482b38f0 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -20,6 +20,7 @@ buildscript { repositories { google() mavenCentral() + maven {url "https://maven.fullstory.com"} } dependencies { classpath("com.android.tools.build:gradle") @@ -27,6 +28,9 @@ buildscript { classpath("com.google.gms:google-services:4.3.4") classpath("com.google.firebase:firebase-crashlytics-gradle:2.7.1") classpath("com.google.firebase:perf-plugin:1.4.1") + // Fullstory integration + classpath ("com.fullstory:gradle-plugin-local:1.45.1") + // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") @@ -70,7 +74,7 @@ allprojects { // 'mapbox' is the fixed username for Mapbox's Maven repository. username = 'mapbox' - // The value for password is read from the 'MAPBOX_DOWNLOADS_TOKEN' gradle property. + // The value for password is read from the 'MAPBOX_DOWNLOADS_TOKEN' gradle property. // Run "npm run setup-mapbox-sdk" to set this property in «USER_HOME»/.gradle/gradle.properties // Example gradle.properties entry: diff --git a/babel.config.js b/babel.config.js index 9f8b7a711d78..0660cdb452fb 100644 --- a/babel.config.js +++ b/babel.config.js @@ -14,6 +14,23 @@ const defaultPlugins = [ // source code transformation as we do not use class property assignment. 'transform-class-properties', + /* Fullstory */ + [ + '@fullstory/react-native', + { + version: '1.4.0', + org: 'o-1WN56P-na1', + enabledVariants: 'all', + }, + ], + [ + '@fullstory/babel-plugin-annotate-react', + { + native: true, + setFSTagName: true, + }, + ], + // Keep it last 'react-native-reanimated/plugin', ]; diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 7cafafca9973..9d397b9557a3 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -98,7 +98,7 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): {from: 'web/apple-touch-icon.png'}, {from: 'assets/images/expensify-app-icon.svg'}, {from: 'web/manifest.json'}, - {from: 'web/gtm.js'}, + {from: 'web/thirdPartyScripts.js'}, {from: 'assets/css', to: 'css'}, {from: 'assets/fonts/web', to: 'fonts'}, {from: 'assets/sounds', to: 'sounds'}, diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md index 0a88ecd7bda8..62629650226e 100644 --- a/contributingGuides/STYLE.md +++ b/contributingGuides/STYLE.md @@ -68,14 +68,48 @@ export { } ``` -Using arrow functions is the preferred way to write an anonymous function such as a callback method. +Using named functions is the preferred way to write a callback method. ```javascript // Bad -_.map(someArray, function (item) {...}); +people.map(function (item) {/* Long and complex logic */}); +people.map((item) => {/* Long and complex logic with many inner loops*/}); +useEffect/useMemo/useCallback(() => {/* Long and complex logic */}, []); // Good -_.map(someArray, (item) => {...}); +function mappingPeople(person) {/* Long and complex logic */}; +people.map(mappingPeople); +useEffect/useMemo/useCallback(function handlingConnection() {/* Long and complex logic */}, []); +``` + +You can still use arrow function for declarations or simple logics to keep them readable. + +```javascript +// Bad +randomList.push({ + onSelected: Utils.checkIfAllowed(function checkTask() { return Utils.canTeamUp(people); }), +}); +routeList.filter(function checkIsActive(route) { + return route.isActive; +}); + +// Good +randomList.push({ + onSelected: Utils.checkIfAllowed(() => Utils.canTeamUp(people)), +}); +routeList.filter((route) => route.isActive); +const myFunction = () => {...}; +const person = { getName: () => {} }; +Utils.connect({ + callback: (val) => {}, +}); +useEffect(() => { + if (isFocused) { + return; + } + setError(null, {}); +}, [isFocused]); + ``` Empty functions (noop) should be declare as arrow functions with no whitespace inside. Avoid _.noop() @@ -123,7 +157,7 @@ myArray.forEach(item => doSomething(item)); _.each(myArray, item => doSomething(item)); // Bad -const myArray = Object.keys(someObject).map(key => doSomething(someObject[key])); +const myArray = Object.keys(someObject).map((key) => doSomething(someObject[key])); // Good const myArray = _.map(someObject, (value, key) => doSomething(value)); @@ -143,7 +177,7 @@ const modifiedArray = _.chain(someArray) ## Accessing Object Properties and Default Values -Use `lodashGet()` to safely access object properties and `||` to short circuit null or undefined values that are not guaranteed to exist in a consistent way throughout the codebase. In the rare case that you want to consider a falsy value as usable and the `||` operator prevents this then be explicit about this in your code and check for the type using an underscore method e.g. `_.isBoolean(value)` or `_.isEqual(0)`. +Use `lodashGet()` to safely access object properties and `||` to short circuit null or undefined values that are not guaranteed to exist in a consistent way throughout the codebase. In the rare case that you want to consider a falsy value as usable and the `||` operator prevents this then be explicit about this in your code and check for the type. ```javascript // Bad @@ -448,7 +482,7 @@ const propTypes = { ### Important Note: -In React Native, one **must not** attempt to falsey-check a string for an inline ternary. Even if it's in curly braces, React Native will try to render it as a `` node and most likely throw an error about trying to render text outside of a `` component. Use `_.isEmpty()` instead. +In React Native, one **must not** attempt to falsey-check a string for an inline ternary. Even if it's in curly braces, React Native will try to render it as a `` node and most likely throw an error about trying to render text outside of a `` component. Use `!!` instead. ```javascript // Bad! This will cause a breaking an error on native platforms @@ -467,7 +501,7 @@ In React Native, one **must not** attempt to falsey-check a string for an inline { return ( - {!_.isEmpty(props.title) + {!!props.title ? {props.title} : null} This is the body diff --git a/contributingGuides/TS_STYLE.md b/contributingGuides/TS_STYLE.md index b96f24a7c949..72d2bdeefba2 100644 --- a/contributingGuides/TS_STYLE.md +++ b/contributingGuides/TS_STYLE.md @@ -684,7 +684,7 @@ _.each(arr, () => {}); // GOOD var arr = []; -arr.forEach(() => {}); +arr.forEach(function loopArr() {}); // BAD lodashGet(object, ['foo'], 'bar'); diff --git a/docs/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot.md b/docs/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot.md new file mode 100644 index 000000000000..5df171a93a72 --- /dev/null +++ b/docs/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot.md @@ -0,0 +1,38 @@ +--- +title: Assign or remove a Copilot +description: Safely delegate tasks without sharing login information. +--- + +You can safely delegate tasks to someone else without sharing your login information by assigning them as your Copilot. Your copilot can access your Expensify account through their own account to: +* Prepare expenses on your behalf +* Approve and reimburse expense reports on your behalf (Full Access Copilots only) +* View and make changes to your account, domain, and workspace settings +* View all expenses visible from your account + +# Assign a Copilot + +1. Hover over Settings and click **Account**. +2. Under Account Details, scroll down to the Copilot: Delegated Access section. +3. Enter the email address or phone number for the person you want to assign as your Copilot. +4. Select whether you want to give your Copilot Full or Submit Only access. + * **Full Access**: Your Copilot will have full access to your account. Nearly every action you can do and everything you can see in your account will also be available to your Copilot. However, Copilots do not have the ability to add or remove other Copilots from your account. + * **Submit Only Access**: Your Copilot will have the same access and limitations as a Full Access Copilot, but they will not be able to approve reports on your behalf—they can only submit them. +5. Click **Invite Copilot**. + +If your Copilot already has an Expensify account, they will get an email notifying them that they can now also access your account from within their own. If they do not have an Expensify account, they will get an email with a link to create one. Once created, they will be able to access your account from within their own. + +# Remove a Copilot + +{% include info.html %} +This action must be completed by the account owner. Copilots cannot remove other Copilots from an account. +{% include end-info.html %} + +1. Hover over Settings and click **Account**. +2. Under Account Details, scroll down to the Copilot: Delegated Access section. +3. Click the red X next to the copilot to remove them. + +# FAQs + +**Can I only have one Copilot?** + +You can assign as many Copilots as you like—there is no limit. diff --git a/docs/articles/expensify-classic/reports/Assign-report-approvers-to-specific-employees.md b/docs/articles/expensify-classic/reports/Assign-report-approvers-to-specific-employees.md new file mode 100644 index 000000000000..5f79ea11378c --- /dev/null +++ b/docs/articles/expensify-classic/reports/Assign-report-approvers-to-specific-employees.md @@ -0,0 +1,25 @@ +--- +title: Assign report approvers to specific employees +description: Create approval hierarchies for reports +--- +
+ +{% include info.html %} +To assign different approvers for different employees, your workspace must use Advanced Approvals as the report approval workflow. +{% include end-info.html %} + +Rather than having one approver for all members of the workspace, you can use the Advanced Approvals workflow to assign different report approvers to specific employees. + +To assign a report approver to a specific member of your workspace, +1. Hover over Settings, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Members** tab on the left. +4. Click **Settings** next to the desired member. +5. Click the “Approves to” dropdown and select the desired approver for the member’s reports. +6. Click **Save**. + +You can also set +- Over-limit approval rules that require a secondary approver when a specific member’s report expenses exceed a set limit. +- Approvers for expenses under a specific tag or category. + +
diff --git a/docs/articles/expensify-classic/reports/Create-a-report-approval-workflow.md b/docs/articles/expensify-classic/reports/Create-a-report-approval-workflow.md new file mode 100644 index 000000000000..015bbe2e8532 --- /dev/null +++ b/docs/articles/expensify-classic/reports/Create-a-report-approval-workflow.md @@ -0,0 +1,25 @@ +--- +title: Create a report approval workflow +description: Set up an approval workflow automation for employee reports +--- +
+ +Expensify allows Workspace Admins to create workflows and automations that determine how expense reports are approved for the workspace. You can choose from three different workflows that either: +- Allow all submitted expenses to be automatically approved (if they don’t have any violations). +- Assign one approver for all reports under the workspace. +- Set up multi-level approvals for more complex workflows. + +# Set approval workflow + +1. Hover over Settings, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Members** tab on the left. +4. Scroll down to the Approval Mode section. +5. Select an approval mode. + - **Submit and Close**: No approval is required. Once a report is submitted, it will be automatically approved and closed. This option may be useful if your expense approvals occur in another system or if the submitter and approver are the same person. + - **Submit and Approve**: All reports go to one person that you assign as the approver. Once a report is submitted, it is sent to the approver. This is the default option. + - **Advanced Approval**: Allows for more complex workflows, like assigning different approvers for different employees or requiring secondary approvals for expenses that exceed a set limit. + +To add to your approval workflow, you can also set up approval rules for specific categories and tags. + +
diff --git a/docs/articles/expensify-classic/reports/Require-review-for-over-limit-expenses.md b/docs/articles/expensify-classic/reports/Require-review-for-over-limit-expenses.md new file mode 100644 index 000000000000..27d8dbf4b6a2 --- /dev/null +++ b/docs/articles/expensify-classic/reports/Require-review-for-over-limit-expenses.md @@ -0,0 +1,46 @@ +--- +title: Require review for over-limit expenses +description: Require a manual review for expenses that exceed a set amount +--- +
+ +You can set rules that require a manual review for expenses that exceed a specific amount. These rules can be set for all expenses under a workspace and/or for a specific member of your workspace. + +{% include info.html %} +These rules do not prohibit purchases over this limit amount. They only ensure that expenses over the limit require a manual review. +{% include end-info.html %} + +# Set a manual approval rule for over-limit expenses + +To set approval limits for expenses submitted to a workspace, +1. Hover over Settings, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Members** tab on the left. +4. Scroll down to the Approval Mode section to where it says Expense Approvals. +5. In the “Manually approve all expenses over:” field, enter the expense limit amount. + +Any expenses that exceed the set limit will now require a manual review, even if the approval workflow does not require manual approval. + +# Set an over-limit approver for a member + +When over-limit approvals are set for a specific member, a secondary approver will be required when the member submits a report that contains expenses exceeding the limit amount. If the member is an approver for other members’ reports, the approval limit applies to those reports as well. + +For example, if you want to allow a project manager to review expenses under $500 but have a department head review expenses over $500, you can assign the department head as the project manager’s over-limit approver. + +{% include info.html %} +To set expense limits for specific workspace members, your workspace must use Advanced Approvals as the report approval workflow. +{% include end-info.html %} + +To set an over-limit approver for a specific member of your workspace, +1. Hover over Settings, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Members** tab on the left. +4. Click **Settings** next to the desired member. +5. In the “If report total is over” field, enter the amount that will require this member’s reports to need a secondary review. This limit also applies to reports that the member is in charge of reviewing. +6. Click the “Then approves to” dropdown and select the secondary approver. +7. Click **Save**. + +
+ + + diff --git a/docs/articles/expensify-classic/reports/Set-a-random-report-audit-schedule.md b/docs/articles/expensify-classic/reports/Set-a-random-report-audit-schedule.md new file mode 100644 index 000000000000..198bd8d78cea --- /dev/null +++ b/docs/articles/expensify-classic/reports/Set-a-random-report-audit-schedule.md @@ -0,0 +1,21 @@ +--- +title: Set a random report audit schedule +description: Randomly audit a percentage of compliant reports +--- +
+ +Expensify automatically flags reports that contain inaccurate or non-compliant expenses for review. However, you can also choose to randomly audit a percentage of compliant reports. + +To set a random audit schedule, +1. Hover over Settings, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Members** tab on the left. +4. Scroll down to the Expense Approvals heading under the Approval Modes section. +5. In the “Randomly route reports for manual approval” field, enter the percentage of reports that you want to be randomly audited. The default is set at 5% (or 1 in 20 reports). +6. Click **Save**. + +
+ + + + diff --git a/docs/articles/expensify-classic/reports/Track-report-history.md b/docs/articles/expensify-classic/reports/Track-report-history.md new file mode 100644 index 000000000000..8e7e86850f2c --- /dev/null +++ b/docs/articles/expensify-classic/reports/Track-report-history.md @@ -0,0 +1,20 @@ +--- +title: Track report history +description: See the comments and history on a report +--- +
+ +All changes and comments that have been made on a report are tracked at the bottom of the report. + +1. Click the **Reports** tab. +2. Open a report. +3. Scroll to the bottom of the report to review the report’s history and comments. + +Additionally, an email notification is sent to the employee when impactful changes are made to the report. For example, if the reimbursable status of an expense is changed, if an expense is approved or denied, or if a comment is added to the report. + +
+ + + + + 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 +--- +
+ +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. + +
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 %} - {{ page.url | replace:'/index.html','/' | absolute_url | xml_escape }} + {{ page.url | replace:'/index.html','/' | absolute_url | xml_escape | replace:'.html','' }} {% endfor %} \ No newline at end of file diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index c9b8286cf50f..440309f63c6e 100644 Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg index 18fbfec9390f..2de81ee85018 100644 Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 7f50db5da85a..e7ce320f65d6 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -681,6 +681,7 @@ "${PODS_ROOT}/Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests-frameworks.sh", "${BUILT_PRODUCTS_DIR}/MapboxMaps/MapboxMaps.framework", "${BUILT_PRODUCTS_DIR}/Turf/Turf.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FullStory/FullStory.framework/FullStory", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCommon/MapboxCommon.framework/MapboxCommon", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCoreMaps/MapboxCoreMaps.framework/MapboxCoreMaps", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxMobileEvents/MapboxMobileEvents.framework/MapboxMobileEvents", @@ -692,6 +693,7 @@ outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Turf.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FullStory.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCommon.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCoreMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMobileEvents.framework", @@ -735,6 +737,7 @@ "${PODS_ROOT}/Target Support Files/Pods-NewExpensify/Pods-NewExpensify-frameworks.sh", "${BUILT_PRODUCTS_DIR}/MapboxMaps/MapboxMaps.framework", "${BUILT_PRODUCTS_DIR}/Turf/Turf.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FullStory/FullStory.framework/FullStory", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCommon/MapboxCommon.framework/MapboxCommon", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCoreMaps/MapboxCoreMaps.framework/MapboxCoreMaps", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxMobileEvents/MapboxMobileEvents.framework/MapboxMobileEvents", @@ -746,6 +749,7 @@ outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Turf.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FullStory.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCommon.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCoreMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMobileEvents.framework", diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index ddcc64604581..38221d8cf645 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.63 + 1.4.65 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,12 @@ CFBundleVersion - 1.4.63.0 + 1.4.65.0 + FullStory + + OrgId + o-1WN56P-na1 + ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index a57ffb9500c5..bdf73b5e5695 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.63 + 1.4.65 CFBundleSignature ???? CFBundleVersion - 1.4.63.0 + 1.4.65.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 3c8e8c1fb63f..1a4de746addf 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.63 + 1.4.65 CFBundleVersion - 1.4.63.0 + 1.4.65.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f564bfd931e4..6ef495a65bd3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -138,6 +138,27 @@ PODS: - GoogleUtilities/Environment (~> 7.7) - "GoogleUtilities/NSData+zlib (~> 7.7)" - fmt (6.2.1) + - FullStory (1.43.1) + - fullstory_react-native (1.4.2): + - FullStory (~> 1.14) + - glog + - hermes-engine + - RCT-Folly (= 2022.05.16.00) + - RCTRequired + - RCTTypeSafety + - React-Codegen + - React-Core + - React-debug + - React-Fabric + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - glog (0.3.5) - GoogleAppMeasurement (8.8.0): - GoogleAppMeasurement/AdIdSupport (= 8.8.0) @@ -1697,25 +1718,6 @@ PODS: - React-perflogger (= 0.73.4) - RNAppleAuthentication (2.2.2): - React-Core - - RNCAsyncStorage (1.21.0): - - glog - - hermes-engine - - RCT-Folly (= 2022.05.16.00) - - RCTRequired - - RCTTypeSafety - - React-Codegen - - React-Core - - React-debug - - React-Fabric - - React-graphics - - React-ImageManager - - React-NativeModulesApple - - React-RCTFabric - - React-rendererdebug - - React-utils - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - Yoga - RNCClipboard (1.13.2): - glog - hermes-engine @@ -2154,7 +2156,6 @@ DEPENDENCIES: - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - "RNAppleAuthentication (from `../node_modules/@invertase/react-native-apple-authentication`)" - - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) @@ -2196,6 +2197,7 @@ SPEC REPOS: - FirebasePerformance - FirebaseRemoteConfig - fmt + - FullStory - GoogleAppMeasurement - GoogleDataTransport - GoogleSignIn @@ -2244,6 +2246,10 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-modules-core" FBLazyVector: :path: "../node_modules/react-native/Libraries/FBLazyVector" + fullstory_react-native: + :path: "../node_modules/@fullstory/react-native" + FBReactNativeSpec: + :path: "../node_modules/react-native/React/FBReactNativeSpec" glog: :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec" hermes-engine: @@ -2385,8 +2391,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" RNAppleAuthentication: :path: "../node_modules/@invertase/react-native-apple-authentication" - RNCAsyncStorage: - :path: "../node_modules/@react-native-async-storage/async-storage" RNCClipboard: :path: "../node_modules/@react-native-clipboard/clipboard" RNCPicker: @@ -2461,6 +2465,8 @@ SPEC CHECKSUMS: FirebasePerformance: 0c01a7a496657d7cea86d40c0b1725259d164c6c FirebaseRemoteConfig: 2d6e2cfdb49af79535c8af8a80a4a5009038ec2b fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 + FullStory: e035758fef275fb59c6471f61b179652aeca452b + fullstory_react-native: a56e2bb52753b69f01aab3ae876087db08488034 glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 GoogleAppMeasurement: 5ba1164e3c844ba84272555e916d0a6d3d977e91 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a @@ -2551,7 +2557,6 @@ SPEC CHECKSUMS: React-utils: 6e5ad394416482ae21831050928ae27348f83487 ReactCommon: 840a955d37b7f3358554d819446bffcf624b2522 RNAppleAuthentication: 0571c08da8c327ae2afc0261b48b4a515b0286a6 - RNCAsyncStorage: 559f22cc4b582414e783fd7255974b29e24b451c RNCClipboard: c73bbc2e9012120161f1012578418827983bfd0c RNCPicker: c77efa39690952647b83d8085520bf50ebf94ecb RNDeviceInfo: cbf78fdb515ae73e641ee7c6b474f77a0299e7e6 @@ -2581,7 +2586,7 @@ SPEC CHECKSUMS: SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 Turf: 13d1a92d969ca0311bbc26e8356cca178ce95da2 VisionCamera: 3033e0dd5272d46e97bcb406adea4ae0e6907abf - Yoga: 1b901a6d6eeba4e8a2e8f308f708691cdb5db312 + Yoga: 64cd2a583ead952b0315d5135bf39e053ae9be70 PODFILE CHECKSUM: a25a81f2b50270f0c0bd0aff2e2ebe4d0b4ec06d diff --git a/jest/setup.ts b/jest/setup.ts index 488e3e36a1d3..174e59a7e493 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -2,9 +2,11 @@ import '@shopify/flash-list/jestSetup'; import 'react-native-gesture-handler/jestSetup'; import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import 'setimmediate'; +import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; setupMockImages(); +mockFSLibrary(); // This mock is required as per setup instructions for react-navigation testing // https://reactnavigation.org/docs/testing/#mocking-native-modules diff --git a/jest/setupMockFullstoryLib.ts b/jest/setupMockFullstoryLib.ts new file mode 100644 index 000000000000..9edfccab9441 --- /dev/null +++ b/jest/setupMockFullstoryLib.ts @@ -0,0 +1,24 @@ +type FSPageInterface = { + start: jest.Mock; +}; + +export default function mockFSLibrary() { + jest.mock('@fullstory/react-native', () => { + class Fullstory { + consent = jest.fn(); + + anonymize = jest.fn(); + + identify = jest.fn(); + } + + return { + FSPage(): FSPageInterface { + return { + start: jest.fn(), + }; + }, + default: Fullstory, + }; + }); +} diff --git a/package-lock.json b/package-lock.json index 920fefc8242b..e92667992321 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.63-0", + "version": "1.4.65-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.63-0", + "version": "1.4.65-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -20,12 +20,13 @@ "@formatjs/intl-locale": "^3.3.0", "@formatjs/intl-numberformat": "^8.5.0", "@formatjs/intl-pluralrules": "^5.2.2", + "@fullstory/browser": "^2.0.3", + "@fullstory/react-native": "^1.4.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.6.0", "@kie/mock-github": "^1.0.0", "@onfido/react-native-sdk": "10.6.0", - "@react-native-async-storage/async-storage": "1.21.0", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.13.2", "@react-native-community/geolocation": "3.2.1", @@ -132,8 +133,7 @@ "react-webcam": "^7.1.1", "react-window": "^1.8.9", "semver": "^7.5.2", - "shim-keyboard-event-key": "^1.0.3", - "underscore": "^1.13.1" + "shim-keyboard-event-key": "^1.0.3" }, "devDependencies": { "@actions/core": "1.10.0", @@ -182,7 +182,6 @@ "@types/react-test-renderer": "^18.0.0", "@types/semver": "^7.5.4", "@types/setimmediate": "^1.0.2", - "@types/underscore": "^1.11.5", "@types/webpack": "^5.28.5", "@types/webpack-bundle-analyzer": "^4.7.0", "@typescript-eslint/eslint-plugin": "^6.13.2", @@ -201,6 +200,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", @@ -5590,6 +5590,52 @@ "tslib": "^2.4.0" } }, + "node_modules/@fullstory/babel-plugin-annotate-react": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@fullstory/babel-plugin-annotate-react/-/babel-plugin-annotate-react-2.3.0.tgz", + "integrity": "sha512-gYLUL6Tu0exbvTIhK9nSCaztmqBlQAm07Fvtl/nKTc+lxwFkcX9vR8RrdTbyjJZKbPaA5EMlExQ6GeLCXkfm5g==" + }, + "node_modules/@fullstory/babel-plugin-react-native": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@fullstory/babel-plugin-react-native/-/babel-plugin-react-native-1.1.0.tgz", + "integrity": "sha512-BqfSUdyrrYrZM286GzdHd3qCdbitxUAIM0Z+HpoOTGWVTLDpkFNNaRw5juq8YhYbcPm6BAtK0RMGY7CvcMNarA==", + "dependencies": { + "@babel/parser": "^7.0.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@fullstory/browser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@fullstory/browser/-/browser-2.0.3.tgz", + "integrity": "sha512-usjH8FB1O2LiSWoblsuKhFhlYDGpIPuyQVOx4JXtxm9QmQARdKZdNq1vPijxuDvOGjhwtVZa4JmhvByRRuDPnQ==", + "dependencies": { + "@fullstory/snippet": "2.0.3" + } + }, + "node_modules/@fullstory/react-native": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@fullstory/react-native/-/react-native-1.4.2.tgz", + "integrity": "sha512-Ig85ghn5UN+Tc1JWL/y4hY9vleeaVHL3f6qH9W4odDNP4XAv29+G82nIYQhBOQGoVnIQ4oQFQftir/dqAbidSw==", + "dependencies": { + "@fullstory/babel-plugin-annotate-react": "^2.2.0", + "@fullstory/babel-plugin-react-native": "^1.1.0" + }, + "peerDependencies": { + "expo": ">=47.0.0", + "react": "*", + "react-native": ">=0.61.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@fullstory/snippet": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@fullstory/snippet/-/snippet-2.0.3.tgz", + "integrity": "sha512-EaCuTQSLv5FvnjHLbTxErn3sS1+nLqf1p6sA/c4PV49stBtkUakA0eLhJJdaw0WLdXyEzZXf86lRNsjEzrgGPw==" + }, "node_modules/@gar/promisify": { "version": "1.1.3", "license": "MIT" @@ -7671,16 +7717,6 @@ } } }, - "node_modules/@react-native-async-storage/async-storage": { - "version": "1.21.0", - "license": "MIT", - "dependencies": { - "merge-options": "^3.0.4" - }, - "peerDependencies": { - "react-native": "^0.0.0-0 || >=0.60 <1.0" - } - }, "node_modules/@react-native-camera-roll/camera-roll": { "version": "7.4.0", "license": "MIT", @@ -12676,11 +12712,6 @@ "version": "4.0.2", "license": "MIT" }, - "node_modules/@types/underscore": { - "version": "1.11.5", - "dev": true, - "license": "MIT" - }, "node_modules/@types/unist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", @@ -17740,6 +17771,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" @@ -23514,13 +23551,6 @@ "node": ">=6" } }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-object": { "version": "5.0.0", "license": "MIT", @@ -27688,16 +27718,6 @@ "version": "1.0.1", "license": "MIT" }, - "node_modules/merge-options": { - "version": "3.0.4", - "license": "MIT", - "dependencies": { - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/merge-refs": { "version": "1.2.1", "license": "MIT", diff --git a/package.json b/package.json index 20d066eabebe..89d80e9651fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.63-0", + "version": "1.4.65-0", "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", @@ -71,12 +72,13 @@ "@formatjs/intl-locale": "^3.3.0", "@formatjs/intl-numberformat": "^8.5.0", "@formatjs/intl-pluralrules": "^5.2.2", + "@fullstory/browser": "^2.0.3", + "@fullstory/react-native": "^1.4.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.6.0", "@kie/mock-github": "^1.0.0", "@onfido/react-native-sdk": "10.6.0", - "@react-native-async-storage/async-storage": "1.21.0", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.13.2", "@react-native-community/geolocation": "3.2.1", @@ -183,8 +185,7 @@ "react-webcam": "^7.1.1", "react-window": "^1.8.9", "semver": "^7.5.2", - "shim-keyboard-event-key": "^1.0.3", - "underscore": "^1.13.1" + "shim-keyboard-event-key": "^1.0.3" }, "devDependencies": { "@actions/core": "1.10.0", @@ -233,7 +234,6 @@ "@types/react-test-renderer": "^18.0.0", "@types/semver": "^7.5.4", "@types/setimmediate": "^1.0.2", - "@types/underscore": "^1.11.5", "@types/webpack": "^5.28.5", "@types/webpack-bundle-analyzer": "^4.7.0", "@typescript-eslint/eslint-plugin": "^6.13.2", @@ -252,6 +252,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+0.73.4+014+iOSCoreAnimationBorderRendering.patch b/patches/react-native+0.73.4+014+iOSCoreAnimationBorderRendering.patch new file mode 100644 index 000000000000..b59729e79622 --- /dev/null +++ b/patches/react-native+0.73.4+014+iOSCoreAnimationBorderRendering.patch @@ -0,0 +1,22 @@ +diff --git a/node_modules/react-native/React/Fabric/Mounting/RCTMountingManager.mm b/node_modules/react-native/React/Fabric/Mounting/RCTMountingManager.mm +index b4cfb3d..7aa00e5 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/RCTMountingManager.mm ++++ b/node_modules/react-native/React/Fabric/Mounting/RCTMountingManager.mm +@@ -49,6 +49,9 @@ static void RCTPerformMountInstructions( + { + SystraceSection s("RCTPerformMountInstructions"); + ++ [CATransaction begin]; ++ [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions]; ++ + for (const auto &mutation : mutations) { + switch (mutation.type) { + case ShadowViewMutation::Create: { +@@ -147,6 +150,7 @@ static void RCTPerformMountInstructions( + } + } + } ++ [CATransaction commit]; + } + + @implementation RCTMountingManager { 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 { + // 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/App.tsx b/src/App.tsx index a3a9f7a3f3b6..006028271c80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import {WindowDimensionsProvider} from './components/withWindowDimensions'; import Expensify from './Expensify'; import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; +import {SuggestionsContextProvider} from './pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; import type {Route} from './ROUTES'; @@ -79,6 +80,7 @@ function App({url}: AppProps) { ActiveElementRoleProvider, ActiveWorkspaceContextProvider, PlaybackContextProvider, + SuggestionsContextProvider, FullScreenContextProvider, VolumeContextProvider, VideoPopoverMenuContextProvider, diff --git a/src/CONST.ts b/src/CONST.ts index a6df33987c8d..c2299f242b6b 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; @@ -66,9 +67,12 @@ const onboardingChoices = { LOOKING_AROUND: 'newDotLookingAround', }; +type OnboardingPurposeType = ValueOf; + const CONST = { MERGED_ACCOUNT_PREFIX: 'MERGED_', DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL], + DEFAULT_COMPOSER_PORTAL_HOST_NAME: 'suggestions_0', // Note: Group and Self-DM excluded as these are not tied to a Workspace WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT], @@ -550,6 +554,7 @@ const CONST = { CONCIERGE_ICON_URL_2021: `${CLOUDFRONT_URL}/images/icons/concierge_2021.png`, CONCIERGE_ICON_URL: `${CLOUDFRONT_URL}/images/icons/concierge_2022.png`, UPWORK_URL: 'https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22', + DEEP_DIVE_EXPENSIFY_CARD: 'https://community.expensify.com/discussion/4848/deep-dive-expensify-card-and-quickbooks-online-auto-reconciliation-how-it-works', GITHUB_URL: 'https://github.com/Expensify/App', TERMS_URL: `${USE_EXPENSIFY_URL}/terms`, PRIVACY_URL: `${USE_EXPENSIFY_URL}/privacy`, @@ -633,59 +638,59 @@ const CONST = { LIMIT: 50, // OldDot Actions render getMessage from Web-Expensify/lib/Report/Action PHP files via getMessageOfOldDotReportAction in ReportActionsUtils.ts TYPE: { - ACTIONABLEJOINREQUEST: 'ACTIONABLEJOINREQUEST', - ACTIONABLEMENTIONWHISPER: 'ACTIONABLEMENTIONWHISPER', - ACTIONABLETRACKEXPENSEWHISPER: 'ACTIONABLETRACKEXPENSEWHISPER', - ADDCOMMENT: 'ADDCOMMENT', + ACTIONABLE_JOIN_REQUEST: 'ACTIONABLEJOINREQUEST', + ACTIONABLE_MENTION_WHISPER: 'ACTIONABLEMENTIONWHISPER', + ACTIONABLE_TRACK_EXPENSE_WHISPER: 'ACTIONABLETRACKEXPENSEWHISPER', + ADD_COMMENT: 'ADDCOMMENT', APPROVED: 'APPROVED', - CHANGEFIELD: 'CHANGEFIELD', // OldDot Action - CHANGEPOLICY: 'CHANGEPOLICY', // OldDot Action - CHANGETYPE: 'CHANGETYPE', // OldDot Action - CHRONOSOOOLIST: 'CHRONOSOOOLIST', + CHANGE_FIELD: 'CHANGEFIELD', // OldDot Action + CHANGE_POLICY: 'CHANGEPOLICY', // OldDot Action + CHANGE_TYPE: 'CHANGETYPE', // OldDot Action + CHRONOS_OOO_LIST: 'CHRONOSOOOLIST', CLOSED: 'CLOSED', CREATED: 'CREATED', - DELEGATESUBMIT: 'DELEGATESUBMIT', // OldDot Action - DELETEDACCOUNT: 'DELETEDACCOUNT', // OldDot Action + DELEGATE_SUBMIT: 'DELEGATESUBMIT', // OldDot Action + DELETED_ACCOUNT: 'DELETEDACCOUNT', // OldDot Action DONATION: 'DONATION', // OldDot Action - EXPORTEDTOCSV: 'EXPORTEDTOCSV', // OldDot Action - EXPORTEDTOINTEGRATION: 'EXPORTEDTOINTEGRATION', // OldDot Action - EXPORTEDTOQUICKBOOKS: 'EXPORTEDTOQUICKBOOKS', // OldDot Action + EXPORTED_TO_CSV: 'EXPORTEDTOCSV', // OldDot Action + EXPORTED_TO_INTEGRATION: 'EXPORTEDTOINTEGRATION', // OldDot Action + EXPORTED_TO_QUICK_BOOKS: 'EXPORTEDTOQUICKBOOKS', // OldDot Action FORWARDED: 'FORWARDED', // OldDot Action HOLD: 'HOLD', - HOLDCOMMENT: 'HOLDCOMMENT', + HOLD_COMMENT: 'HOLDCOMMENT', IOU: 'IOU', - INTEGRATIONSMESSAGE: 'INTEGRATIONSMESSAGE', // OldDot Action - MANAGERATTACHRECEIPT: 'MANAGERATTACHRECEIPT', // OldDot Action - MANAGERDETACHRECEIPT: 'MANAGERDETACHRECEIPT', // OldDot Action - MARKEDREIMBURSED: 'MARKEDREIMBURSED', // OldDot Action - MARKREIMBURSEDFROMINTEGRATION: 'MARKREIMBURSEDFROMINTEGRATION', // OldDot Action - MODIFIEDEXPENSE: 'MODIFIEDEXPENSE', + INTEGRATIONS_MESSAGE: 'INTEGRATIONSMESSAGE', // OldDot Action + MANAGER_ATTACH_RECEIPT: 'MANAGERATTACHRECEIPT', // OldDot Action + MANAGER_DETACH_RECEIPT: 'MANAGERDETACHRECEIPT', // OldDot Action + MARKED_REIMBURSED: 'MARKEDREIMBURSED', // OldDot Action + MARK_REIMBURSED_FROM_INTEGRATION: 'MARKREIMBURSEDFROMINTEGRATION', // OldDot Action + MODIFIED_EXPENSE: 'MODIFIEDEXPENSE', MOVED: 'MOVED', - OUTDATEDBANKACCOUNT: 'OUTDATEDBANKACCOUNT', // OldDot Action - REIMBURSEMENTACHBOUNCE: 'REIMBURSEMENTACHBOUNCE', // OldDot Action - REIMBURSEMENTACHCANCELLED: 'REIMBURSEMENTACHCANCELLED', // OldDot Action - REIMBURSEMENTACCOUNTCHANGED: 'REIMBURSEMENTACCOUNTCHANGED', // OldDot Action - REIMBURSEMENTDELAYED: 'REIMBURSEMENTDELAYED', // OldDot Action - REIMBURSEMENTQUEUED: 'REIMBURSEMENTQUEUED', - REIMBURSEMENTDEQUEUED: 'REIMBURSEMENTDEQUEUED', - REIMBURSEMENTREQUESTED: 'REIMBURSEMENTREQUESTED', // OldDot Action - REIMBURSEMENTSETUP: 'REIMBURSEMENTSETUP', // OldDot Action + OUTDATED_BANK_ACCOUNT: 'OUTDATEDBANKACCOUNT', // OldDot Action + REIMBURSEMENT_ACH_BOUNCE: 'REIMBURSEMENTACHBOUNCE', // OldDot Action + REIMBURSEMENT_ACH_CANCELLED: 'REIMBURSEMENTACHCANCELLED', // OldDot Action + REIMBURSEMENT_ACCOUNT_CHANGED: 'REIMBURSEMENTACCOUNTCHANGED', // OldDot Action + REIMBURSEMENT_DELAYED: 'REIMBURSEMENTDELAYED', // OldDot Action + REIMBURSEMENT_QUEUED: 'REIMBURSEMENTQUEUED', + REIMBURSEMENT_DEQUEUED: 'REIMBURSEMENTDEQUEUED', + REIMBURSEMENT_REQUESTED: 'REIMBURSEMENTREQUESTED', // OldDot Action + REIMBURSEMENT_SETUP: 'REIMBURSEMENTSETUP', // OldDot Action RENAMED: 'RENAMED', - REPORTPREVIEW: 'REPORTPREVIEW', - SELECTEDFORRANDOMAUDIT: 'SELECTEDFORRANDOMAUDIT', // OldDot Action + REPORT_PREVIEW: 'REPORTPREVIEW', + SELECTED_FOR_RANDOM_AUDIT: 'SELECTEDFORRANDOMAUDIT', // OldDot Action SHARE: 'SHARE', // OldDot Action - STRIPEPAID: 'STRIPEPAID', // OldDot Action + STRIPE_PAID: 'STRIPEPAID', // OldDot Action SUBMITTED: 'SUBMITTED', - TAKECONTROL: 'TAKECONTROL', // OldDot Action - TASKCANCELLED: 'TASKCANCELLED', - TASKCOMPLETED: 'TASKCOMPLETED', - TASKEDITED: 'TASKEDITED', - TASKREOPENED: 'TASKREOPENED', + TAKE_CONTROL: 'TAKECONTROL', // OldDot Action + TASK_CANCELLED: 'TASKCANCELLED', + TASK_COMPLETED: 'TASKCOMPLETED', + TASK_EDITED: 'TASKEDITED', + TASK_REOPENED: 'TASKREOPENED', UNAPPROVED: 'UNAPPROVED', // OldDot Action UNHOLD: 'UNHOLD', UNSHARE: 'UNSHARE', // OldDot Action - UPDATEGROUPCHATMEMBERROLE: 'UPDATEGROUPCHATMEMBERROLE', - POLICYCHANGELOG: { + UPDATE_GROUP_CHAT_MEMBER_ROLE: 'UPDATEGROUPCHATMEMBERROLE', + POLICY_CHANGE_LOG: { ADD_APPROVER_RULE: 'POLICYCHANGELOG_ADD_APPROVER_RULE', ADD_BUDGET: 'POLICYCHANGELOG_ADD_BUDGET', ADD_CATEGORY: 'POLICYCHANGELOG_ADD_CATEGORY', @@ -713,16 +718,16 @@ const CONST = { REMOVE_FROM_ROOM: 'POLICYCHANGELOG_REMOVEFROMROOM', LEAVE_ROOM: 'POLICYCHANGELOG_LEAVEROOM', REPLACE_CATEGORIES: 'POLICYCHANGELOG_REPLACE_CATEGORIES', - SET_AUTOREIMBURSEMENT: 'POLICYCHANGELOG_SET_AUTOREIMBURSEMENT', + SET_AUTO_REIMBURSEMENT: 'POLICYCHANGELOG_SET_AUTOREIMBURSEMENT', SET_AUTO_JOIN: 'POLICYCHANGELOG_SET_AUTO_JOIN', SET_CATEGORY_NAME: 'POLICYCHANGELOG_SET_CATEGORY_NAME', SHARED_BUDGET_NOTIFICATION: 'POLICYCHANGELOG_SHARED_BUDGET_NOTIFICATION', UPDATE_ACH_ACCOUNT: 'POLICYCHANGELOG_UPDATE_ACH_ACCOUNT', UPDATE_APPROVER_RULE: 'POLICYCHANGELOG_UPDATE_APPROVER_RULE', UPDATE_AUDIT_RATE: 'POLICYCHANGELOG_UPDATE_AUDIT_RATE', - UPDATE_AUTOHARVESTING: 'POLICYCHANGELOG_UPDATE_AUTOHARVESTING', - UPDATE_AUTOREIMBURSEMENT: 'POLICYCHANGELOG_UPDATE_AUTOREIMBURSEMENT', - UPDATE_AUTOREPORTING_FREQUENCY: 'POLICYCHANGELOG_UPDATE_AUTOREPORTING_FREQUENCY', + UPDATE_AUTO_HARVESTING: 'POLICYCHANGELOG_UPDATE_AUTOHARVESTING', + UPDATE_AUTO_REIMBURSEMENT: 'POLICYCHANGELOG_UPDATE_AUTOREIMBURSEMENT', + UPDATE_AUTO_REPORTING_FREQUENCY: 'POLICYCHANGELOG_UPDATE_AUTOREPORTING_FREQUENCY', UPDATE_BUDGET: 'POLICYCHANGELOG_UPDATE_BUDGET', UPDATE_CATEGORY: 'POLICYCHANGELOG_UPDATE_CATEGORY', UPDATE_CURRENCY: 'POLICYCHANGELOG_UPDATE_CURRENCY', @@ -753,7 +758,7 @@ const CONST = { UPDATE_TIME_RATE: 'POLICYCHANGELOG_UPDATE_TIME_RATE', LEAVE_POLICY: 'POLICYCHANGELOG_LEAVE_POLICY', }, - ROOMCHANGELOG: { + ROOM_CHANGE_LOG: { INVITE_TO_ROOM: 'INVITETOROOM', REMOVE_FROM_ROOM: 'REMOVEFROMROOM', LEAVE_ROOM: 'LEAVEROOM', @@ -1132,6 +1137,11 @@ const CONST = { JPEG: 'image/jpeg', }, + IMAGE_OBJECT_POSITION: { + TOP: 'top', + INITIAL: 'initial', + }, + FILE_TYPE_REGEX: { // Image MimeTypes allowed by iOS photos app. IMAGE: /\.(jpg|jpeg|png|webp|gif|tiff|bmp|heic|heif)$/, @@ -1162,6 +1172,7 @@ const CONST = { EMOJI_PICKER_HEADER_HEIGHT: 32, RECIPIENT_LOCAL_TIME_HEIGHT: 25, AUTO_COMPLETE_SUGGESTER: { + EDIT_SUGGESTER_PADDING: 8, SUGGESTER_PADDING: 6, SUGGESTER_INNER_PADDING: 8, SUGGESTION_ROW_HEIGHT: 40, @@ -1217,12 +1228,30 @@ const CONST = { }, QUICKBOOKS_ONLINE: 'quickbooksOnline', - QUICKBOOKS_IMPORTS: { + QUICK_BOOKS_CONFIG: { SYNC_CLASSES: 'syncClasses', ENABLE_NEW_CATEGORIES: 'enableNewCategories', SYNC_CUSTOMERS: 'syncCustomers', SYNC_LOCATIONS: 'syncLocations', SYNC_TAXES: 'syncTaxes', + PREFERRED_EXPORTER: 'exporter', + EXPORT_DATE: 'exportDate', + OUT_OF_POCKET_EXPENSES: 'outOfPocketExpenses', + EXPORT_INVOICE: 'exportInvoice', + EXPORT_ENTITY: 'exportEntity', + EXPORT_ACCOUNT: 'exportAccount', + EXPORT_COMPANY_CARD: 'exportCompanyCard', + AUTO_SYNC: 'autoSync', + SYNCE_PEOPLE: 'syncPeople', + AUTO_CREATE_VENDOR: 'autoCreateVendor', + REIMBURSEMENT_ACCOUNT_ID: 'reimbursementAccountID', + COLLECTION_ACCOUNT_ID: 'collectionAccountID', + }, + + QUICKBOOKS_EXPORT_ENTITY: { + VENDOR_BILL: 'vendorBill', + CHECK: 'check', + JOURNAL_ENTRY: 'journalEntry', }, ACCOUNT_ID: { @@ -1411,16 +1440,18 @@ const CONST = { ACTION: { EDIT: 'edit', CREATE: 'create', - MOVE: 'move', + SUBMIT: 'submit', CATEGORIZE: 'categorize', SHARE: 'share', }, DEFAULT_AMOUNT: 0, TYPE: { SEND: 'send', + PAY: 'pay', SPLIT: 'split', REQUEST: 'request', - TRACK_EXPENSE: 'track-expense', + SUBMIT: 'submit', + TRACK: 'track', }, REQUEST_TYPE: { DISTANCE: 'distance', @@ -1436,7 +1467,6 @@ const CONST = { DELETE: 'delete', APPROVE: 'approve', TRACK: 'track', - MOVE: 'move', }, AMOUNT_MAX_LENGTH: 10, RECEIPT_STATE: { @@ -3299,6 +3329,13 @@ const CONST = { SCAN: 'scan', DISTANCE: 'distance', }, + TAB_SEARCH: { + ALL: 'all', + SENT: 'sent', + DRAFTS: 'drafts', + WAITING_ON_YOU: 'waitingOnYou', + FINISHED: 'finished', + }, STATUS_TEXT_MAX_LENGTH: 100, DROPDOWN_BUTTON_SIZE: { @@ -3371,9 +3408,9 @@ const CONST = { REFERRAL_PROGRAM: { CONTENT_TYPES: { - MONEY_REQUEST: 'request', + SUBMIT_EXPENSE: 'submitExpense', START_CHAT: 'startChat', - SEND_MONEY: 'sendMoney', + PAY_SOMEONE: 'paySomeone', REFER_FRIEND: 'referralFriend', SHARE_CODE: 'shareCode', }, @@ -3552,7 +3589,7 @@ const CONST = { "# Let's start tracking your expenses!\n" + '\n' + "To track your expenses, create a workspace to keep everything in one place. Here's how:\n" + - '1. From the home screen, click the green + button > New Workspace\n' + + '1. From the home screen, click the green + button > *New Workspace*\n' + '2. Give your workspace a name (e.g. "My business expenses").\n' + '\n' + 'Then, add expenses to your workspace:\n' + @@ -3565,7 +3602,7 @@ const CONST = { '# Expensify is the fastest way to get paid back!\n' + '\n' + 'To submit expenses for reimbursement:\n' + - '1. From the home screen, click the green + button > Request money.\n' + + '1. From the home screen, click the green + button > *Request money*.\n' + "2. Enter an amount or scan a receipt, then input your boss's email.\n" + '\n' + "That'll send a request to get you paid back. Let me know if you have any questions!", @@ -3573,7 +3610,7 @@ const CONST = { "# Let's start managing your team's expenses!\n" + '\n' + "To manage your team's expenses, create a workspace to keep everything in one place. Here's how:\n" + - '1. From the home screen, click the green + button > New Workspace\n' + + '1. From the home screen, click the green + button > *New Workspace*\n' + '2. Give your workspace a name (e.g. "Sales team expenses").\n' + '\n' + 'Then, invite your team to your workspace via the Members pane and [connect a business bank account](https://help.expensify.com/articles/new-expensify/bank-accounts/Connect-a-Bank-Account) to reimburse them. Let me know if you have any questions!', @@ -3581,7 +3618,7 @@ const CONST = { "# Let's start tracking your expenses! \n" + '\n' + "To track your expenses, create a workspace to keep everything in one place. Here's how:\n" + - '1. From the home screen, click the green + button > New Workspace\n' + + '1. From the home screen, click the green + button > *New Workspace*\n' + '2. Give your workspace a name (e.g. "My expenses").\n' + '\n' + 'Then, add expenses to your workspace:\n' + @@ -3594,12 +3631,275 @@ const CONST = { '# Splitting the bill is as easy as a conversation!\n' + '\n' + 'To split an expense:\n' + - '1. From the home screen, click the green + button > Request money.\n' + + '1. From the home screen, click the green + button > *Request money*.\n' + '2. Enter an amount or scan a receipt, then choose who you want to split it with.\n' + '\n' + "We'll send a request to each person so they can pay you back. Let me know if you have any questions!", }, + ONBOARDING_MESSAGES: { + [onboardingChoices.TRACK]: { + message: 'Here are some essential tasks to keep your business spend in shape for tax season.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'createWorkspace', + autoCompleted: true, + title: 'Create a workspace', + subtitle: 'Create a workspace to track expenses, scan receipts, chat, and more.', + message: + 'Here’s how to create a workspace:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Workspaces > New workspace.\n' + + '\n' + + 'Your new workspace is ready! It’ll keep all of your spend (and chats) in one place.', + }, + { + type: 'trackExpense', + autoCompleted: false, + title: 'Track an expense', + subtitle: 'Track an expense in any currency, in just a few clicks.', + message: + 'Here’s how to track an expense:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Track expense.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Click Track.\n' + + '\n' + + 'And you’re done! Yep, it’s that easy.', + }, + ], + }, + [onboardingChoices.EMPLOYER]: { + message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'submitExpense', + autoCompleted: false, + title: 'Submit an expense', + subtitle: 'Submit an expense by entering an amount or scanning a receipt.', + message: + 'Here’s how to submit an expense:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Submit expense.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Add your reimburser to the request.\n' + + '\n' + + 'Then, send your request and wait for that sweet “Cha-ching!” when it’s complete.', + }, + { + type: 'enableWallet', + autoCompleted: false, + title: 'Enable your wallet', + subtitle: 'You’ll need to enable your Expensify Wallet to get paid back. Don’t worry, it’s easy!', + message: + 'Here’s how to set up your wallet:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Wallet > Enable wallet.\n' + + '3. Connect your bank account.\n' + + '\n' + + 'Once that’s done, you can request money from anyone and get paid back right into your personal bank account.', + }, + ], + }, + [onboardingChoices.MANAGE_TEAM]: { + message: 'Here are some important tasks to help get your team’s expenses under control.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'createWorkspace', + autoCompleted: true, + title: 'Create a workspace', + subtitle: 'Create a workspace to track expenses, scan receipts, chat, and more.', + message: + 'Here’s how to create a workspace:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Workspaces > New workspace.\n' + + '\n' + + 'Your new workspace is ready! It’ll keep all of your spend (and chats) in one place.', + }, + { + type: 'meetGuide', + autoCompleted: false, + title: 'Meet your setup specialist', + subtitle: '', + message: ({adminsRoomLink, guideCalendarLink}: {adminsRoomLink: string; guideCalendarLink: string}) => + `Meet your setup specialist, who can answer any questions as you get started with Expensify. Yes, a real human!\n` + + '\n' + + `Chat with the specialist in your [#admins room](${adminsRoomLink}) or [schedule a call](${guideCalendarLink}) today.`, + }, + { + type: 'setupCategories', + autoCompleted: false, + title: 'Set up categories', + subtitle: 'Set up categories so your team can code expenses for easy reporting.', + message: + 'Here’s how to set up categories:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Go to Workspaces > [your workspace].\n' + + '3. Click Categories.\n' + + '4. Enable and disable default categories.\n' + + '5. Click Add categories to make your own.\n' + + '\n' + + 'For more controls like requiring a category for every expense, click Settings.', + }, + { + type: 'addExpenseApprovals', + autoCompleted: false, + title: 'Add expense approvals', + subtitle: 'Add expense approvals to review your team’s spend and keep it under control.', + message: + 'Here’s how to add expense approvals:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Go to Workspaces > [your workspace].\n' + + '3. Click More features.\n' + + '4. Enable Workflows.\n' + + '5. In Workflows, enable Add approvals.\n' + + '\n' + + 'You’ll be set as the expense approver. You can change this to any admin once you invite your team.', + }, + { + type: 'inviteTeam', + autoCompleted: false, + title: 'Invite your team', + subtitle: 'Invite your team to Expensify so they can start tracking expenses today.', + message: + 'Here’s how to invite your team:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Go to Workspaces > [your workspace].\n' + + '3. Click Members > Invite member.\n' + + '4. Enter emails or phone numbers. \n' + + '5. Add an invite message if you want.\n' + + '\n' + + 'That’s it! Happy expensing :)', + }, + ], + }, + [onboardingChoices.PERSONAL_SPEND]: { + message: 'Here’s how to track your spend in a few clicks.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'trackExpense', + autoCompleted: false, + title: 'Track an expense', + subtitle: 'Track an expense in any currency, whether you have a receipt or not.', + message: + 'Here’s how to track an expense:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Track expense.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Click Track.\n' + + '\n' + + 'And you’re done! Yep, it’s that easy.', + }, + ], + }, + [onboardingChoices.CHAT_SPLIT]: { + message: 'Splitting bills with friends is as easy as sending a message. Here’s how.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'startChat', + autoCompleted: false, + title: 'Start a chat', + subtitle: 'Start a chat with a friend or group using their email or phone number.', + message: + 'Here’s how to start a chat:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Start chat.\n' + + '3. Enter emails or phone numbers.\n' + + '\n' + + 'If any of your friends aren’t using Expensify already, they’ll be invited automatically. \n' + + '\n' + + 'Every chat will also turn into an email or text that they can respond to directly.', + }, + { + type: 'splitExpense', + autoCompleted: false, + title: 'Split an expense', + subtitle: 'Split an expense right in your chat with one or more friends.', + message: + 'Here’s how to request money:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Split expense.\n' + + '3. Scan a receipt or enter an amount.\n' + + '4. Add your friend(s) to the request.\n' + + '\n' + + 'Feel free to add more details if you want, or just send it off. Let’s get you paid back!', + }, + { + type: 'enableWallet', + autoCompleted: false, + title: 'Enable your wallet', + subtitle: 'You’ll need to enable your Expensify Wallet to get paid back. Don’t worry, it’s easy!', + message: + 'Here’s how to enable your wallet:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Wallet > Enable wallet.\n' + + '3. Add your bank account.\n' + + '\n' + + 'Once that’s done, you can request money from anyone and get paid right into your personal bank account.', + }, + ], + }, + [onboardingChoices.LOOKING_AROUND]: { + message: + "Expensify is best known for expense and corporate card management, but we do a lot more than that. Let me know what you're interested in and I'll help get you started.", + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [], + }, + }, + REPORT_FIELD_TITLE_FIELD_ID: 'text_title', MOBILE_PAGINATION_SIZE: 15, @@ -4313,6 +4613,12 @@ const CONST = { }, }, + QUICKBOOKS_EXPORT_DATE: { + LAST_EXPENSE: 'lastExpense', + EXPORTED_DATE: 'exportedDate', + SUBMITTED_DATA: 'submittedData', + }, + SESSION_STORAGE_KEYS: { INITIAL_URL: 'INITIAL_URL', }, @@ -4341,8 +4647,10 @@ const CONST = { } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; + type IOUType = ValueOf; +type IOUAction = ValueOf; -export type {Country, IOUType}; +export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType}; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 7c9247bcdbd7..45c2d152542d 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -2,6 +2,7 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; import type * as FormTypes from './types/form'; import type * as OnyxTypes from './types/onyx'; +import type Onboarding from './types/onyx/Onboarding'; import type AssertTypesEqual from './types/utils/AssertTypesEqual'; import type DeepValueOf from './types/utils/DeepValueOf'; @@ -114,6 +115,9 @@ const ONYXKEYS = { /** Boolean flag only true when first set */ NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'nvp_isFirstTimeNewExpensifyUser', + /** This NVP contains information about whether the onboarding flow was completed or not */ + NVP_ONBOARDING: 'nvp_onboarding', + /** Contains the user preference for the LHN priority mode */ NVP_PRIORITY_MODE: 'nvp_priorityMode', @@ -344,8 +348,7 @@ const ONYXKEYS = { TRANSACTION: 'transactions_', TRANSACTION_VIOLATIONS: 'transactionViolations_', TRANSACTION_DRAFT: 'transactionsDraft_', - - // Holds temporary transactions used during the creation and edit flow + SKIP_CONFIRMATION: 'skipConfirmation_', TRANSACTION_BACKUP: 'transactionsBackup_', SPLIT_TRANSACTION_DRAFT: 'splitTransactionDraft_', PRIVATE_NOTES_DRAFT: 'privateNotesDraft_', @@ -386,6 +389,8 @@ const ONYXKEYS = { DISPLAY_NAME_FORM_DRAFT: 'displayNameFormDraft', ONBOARDING_PERSONAL_DETAILS_FORM: 'onboardingPersonalDetailsForm', ONBOARDING_PERSONAL_DETAILS_FORM_DRAFT: 'onboardingPersonalDetailsFormDraft', + ONBOARDING_PERSONAL_WORK: 'onboardingWorkForm', + ONBOARDING_PERSONAL_WORK_DRAFT: 'onboardingWorkFormDraft', ROOM_NAME_FORM: 'roomNameForm', ROOM_NAME_FORM_DRAFT: 'roomNameFormDraft', REPORT_DESCRIPTION_FORM: 'reportDescriptionForm', @@ -476,6 +481,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM]: FormTypes.DisplayNameForm; + [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_WORK]: FormTypes.WorkForm; [ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm; [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.ReportDescriptionForm; [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.LegalNameForm; @@ -546,6 +552,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup; [ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction; [ONYXKEYS.COLLECTION.TRANSACTION_DRAFT]: OnyxTypes.Transaction; + [ONYXKEYS.COLLECTION.SKIP_CONFIRMATION]: boolean; [ONYXKEYS.COLLECTION.TRANSACTION_BACKUP]: OnyxTypes.Transaction; [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations; [ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT]: OnyxTypes.Transaction; @@ -562,6 +569,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ACCOUNT]: OnyxTypes.Account; [ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string; [ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER]: boolean; + [ONYXKEYS.NVP_ONBOARDING]: Onboarding | []; [ONYXKEYS.ACTIVE_CLIENTS]: string[]; [ONYXKEYS.DEVICE_ID]: string; [ONYXKEYS.IS_SIDEBAR_LOADED]: boolean; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7d73d8e55503..809f0778c3e4 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1,5 +1,6 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; +import type {IOUAction, IOUType} from './CONST'; import type {IOURequestType} from './libs/actions/IOU'; import type AssertTypesNotEqual from './types/utils/AssertTypesNotEqual'; @@ -22,6 +23,11 @@ const ROUTES = { ALL_SETTINGS: 'all-settings', + SEARCH: { + route: '/search/:query', + getRoute: (query: string) => `search/${query}` as const, + }, + // This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated CONCIERGE: 'concierge', FLAG_COMMENT: { @@ -202,11 +208,6 @@ const ROUTES = { route: 'r/:reportID/avatar', getRoute: (reportID: string) => `r/${reportID}/avatar` as const, }, - EDIT_REQUEST: { - route: 'r/:threadReportID/edit/:field/:tagIndex?', - getRoute: (threadReportID: string, field: ValueOf, tagIndex?: number) => - `r/${threadReportID}/edit/${field}${typeof tagIndex === 'number' ? `/${tagIndex}` : ''}` as const, - }, EDIT_CURRENCY_REQUEST: { route: 'r/:threadReportID/edit/currency', getRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}` as const, @@ -274,7 +275,7 @@ const ROUTES = { EDIT_SPLIT_BILL: { route: `r/:reportID/split/:reportActionID/edit/:field/:tagIndex?`, getRoute: (reportID: string, reportActionID: string, field: ValueOf, tagIndex?: number) => - `r/${reportID}/split/${reportActionID}/edit/${field}${typeof tagIndex === 'number' ? `/${tagIndex}` : ''}` as const, + `r/${reportID}/split/${reportActionID}/edit/${field as string}${typeof tagIndex === 'number' ? `/${tagIndex}` : ''}` as const, }, TASK_TITLE: { route: 'r/:reportID/title', @@ -304,141 +305,133 @@ const ROUTES = { route: 'r/:reportID/invite/:role?', getRoute: (reportID: string, role?: string) => `r/${reportID}/invite/${role}` as const, }, - MONEY_REQUEST_PARTICIPANTS: { - route: ':iouType/new/participants/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}` as const, - }, MONEY_REQUEST_HOLD_REASON: { - route: ':iouType/edit/reason/:transactionID?', - getRoute: (iouType: string, transactionID: string, reportID: string, backTo: string) => `${iouType}/edit/reason/${transactionID}?backTo=${backTo}&reportID=${reportID}` as const, + route: ':type/edit/reason/:transactionID?', + getRoute: (type: ValueOf, transactionID: string, reportID: string, backTo: string) => + `${type}/edit/reason/${transactionID}?backTo=${backTo}&reportID=${reportID}` as const, }, MONEY_REQUEST_MERCHANT: { route: ':iouType/new/merchant/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}` as const, + getRoute: (iouType: IOUType, reportID = '') => `${iouType}/new/merchant/${reportID}` as const, }, MONEY_REQUEST_RECEIPT: { route: ':iouType/new/receipt/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}` as const, + getRoute: (iouType: IOUType, reportID = '') => `${iouType}/new/receipt/${reportID}` as const, }, MONEY_REQUEST_CREATE: { route: ':action/:iouType/start/:transactionID/:reportID', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => - `${action}/${iouType}/start/${transactionID}/${reportID}` as const, + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => `${action as string}/${iouType as string}/start/${transactionID}/${reportID}` as const, }, MONEY_REQUEST_STEP_CONFIRMATION: { route: ':action/:iouType/confirmation/:transactionID/:reportID', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => - `${action}/${iouType}/confirmation/${transactionID}/${reportID}` as const, + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => + `${action as string}/${iouType as string}/confirmation/${transactionID}/${reportID}` as const, }, MONEY_REQUEST_STEP_AMOUNT: { route: ':action/:iouType/amount/:transactionID/:reportID', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/amount/${transactionID}/${reportID}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/amount/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_TAX_RATE: { route: ':action/:iouType/taxRate/:transactionID/:reportID?', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/taxRate/${transactionID}/${reportID}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/taxRate/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_TAX_AMOUNT: { route: ':action/:iouType/taxAmount/:transactionID/:reportID?', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/taxAmount/${transactionID}/${reportID}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/taxAmount/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_CATEGORY: { route: ':action/:iouType/category/:transactionID/:reportID/:reportActionID?', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => - getUrlWithBackToParam(`${action}/${iouType}/category/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => + getUrlWithBackToParam(`${action as string}/${iouType as string}/category/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), }, MONEY_REQUEST_STEP_CURRENCY: { route: ':action/:iouType/currency/:transactionID/:reportID/:pageIndex?', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, pageIndex = '', currency = '', backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/currency/${transactionID}/${reportID}/${pageIndex}?currency=${currency}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, pageIndex = '', currency = '', backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/currency/${transactionID}/${reportID}/${pageIndex}?currency=${currency}`, backTo), }, MONEY_REQUEST_STEP_DATE: { route: ':action/:iouType/date/:transactionID/:reportID', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/date/${transactionID}/${reportID}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/date/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_DESCRIPTION: { route: ':action/:iouType/description/:transactionID/:reportID/:reportActionID?', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => - getUrlWithBackToParam(`${action}/${iouType}/description/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => + getUrlWithBackToParam(`${action as string}/${iouType as string}/description/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), }, MONEY_REQUEST_STEP_DISTANCE: { route: ':action/:iouType/distance/:transactionID/:reportID', + 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, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/distance/${transactionID}/${reportID}`, backTo), + getUrlWithBackToParam(`${action}/${iouType}/distanceRate/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_MERCHANT: { route: ':action/:iouType/merchant/:transactionID/:reportID', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/merchant/${transactionID}/${reportID}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/merchant/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_PARTICIPANTS: { route: ':action/:iouType/participants/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '', action: ValueOf = 'create') => - getUrlWithBackToParam(`${action}/${iouType}/participants/${transactionID}/${reportID}`, backTo), + getRoute: (iouType: IOUType, transactionID: string, reportID: string, backTo = '', action: IOUAction = 'create') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/participants/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_SCAN: { route: ':action/:iouType/scan/:transactionID/:reportID', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/scan/${transactionID}/${reportID}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/scan/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_TAG: { route: ':action/:iouType/tag/:orderWeight/:transactionID/:reportID/:reportActionID?', - getRoute: ( - action: ValueOf, - iouType: ValueOf, - orderWeight: number, - transactionID: string, - reportID: string, - backTo = '', - reportActionID?: string, - ) => getUrlWithBackToParam(`${action}/${iouType}/tag/${orderWeight}/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, orderWeight: number, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => + getUrlWithBackToParam(`${action as string}/${iouType as string}/tag/${orderWeight}/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), }, MONEY_REQUEST_STEP_WAYPOINT: { route: ':action/:iouType/waypoint/:transactionID/:reportID/:pageIndex', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID?: string, pageIndex = '', backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/waypoint/${transactionID}/${reportID}/${pageIndex}`, backTo), + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID?: string, pageIndex = '', backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/waypoint/${transactionID}/${reportID}/${pageIndex}`, backTo), }, // This URL is used as a redirect to one of the create tabs below. This is so that we can message users with a link // straight to those flows without needing to have optimistic transaction and report IDs. MONEY_REQUEST_START: { route: 'start/:iouType/:iouRequestType', - getRoute: (iouType: ValueOf, iouRequestType: IOURequestType) => `start/${iouType}/${iouRequestType}` as const, + getRoute: (iouType: IOUType, iouRequestType: IOURequestType) => `start/${iouType as string}/${iouRequestType}` as const, }, MONEY_REQUEST_CREATE_TAB_DISTANCE: { route: ':action/:iouType/start/:transactionID/:reportID/distance', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => - `create/${iouType}/start/${transactionID}/${reportID}/distance` as const, + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => `create/${iouType as string}/start/${transactionID}/${reportID}/distance` as const, }, MONEY_REQUEST_CREATE_TAB_MANUAL: { route: ':action/:iouType/start/:transactionID/:reportID/manual', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => - `${action}/${iouType}/start/${transactionID}/${reportID}/manual` as const, + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => + `${action as string}/${iouType as string}/start/${transactionID}/${reportID}/manual` as const, }, MONEY_REQUEST_CREATE_TAB_SCAN: { route: ':action/:iouType/start/:transactionID/:reportID/scan', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => - `create/${iouType}/start/${transactionID}/${reportID}/scan` as const, + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => `create/${iouType as string}/start/${transactionID}/${reportID}/scan` as const, }, MONEY_REQUEST_STATE_SELECTOR: { - route: 'request/state', + route: 'submit/state', getRoute: (state?: string, backTo?: string, label?: string) => - `${getUrlWithBackToParam(`request/state${state ? `?state=${encodeURIComponent(state)}` : ''}`, backTo)}${ + `${getUrlWithBackToParam(`submit/state${state ? `?state=${encodeURIComponent(state)}` : ''}`, backTo)}${ // the label param can be an empty string so we cannot use a nullish ?? operator // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing label ? `${backTo || state ? '&' : '?'}label=${encodeURIComponent(label)}` : '' }` as const, }, - IOU_REQUEST: 'request/new', - IOU_SEND: 'send/new', - IOU_SEND_ADD_BANK_ACCOUNT: 'send/new/add-bank-account', - IOU_SEND_ADD_DEBIT_CARD: 'send/new/add-debit-card', - IOU_SEND_ENABLE_PAYMENTS: 'send/new/enable-payments', + IOU_REQUEST: 'submit/new', + IOU_SEND: 'pay/new', + IOU_SEND_ADD_BANK_ACCOUNT: 'pay/new/add-bank-account', + IOU_SEND_ADD_DEBIT_CARD: 'pay/new/add-debit-card', + IOU_SEND_ENABLE_PAYMENTS: 'pay/new/enable-payments', NEW_TASK: 'new/task', NEW_TASK_ASSIGNEE: 'new/task/assignee', @@ -447,10 +440,6 @@ const ROUTES = { NEW_TASK_TITLE: 'new/task/title', NEW_TASK_DESCRIPTION: 'new/task/description', - ONBOARD: 'onboard', - ONBOARD_MANAGE_EXPENSES: 'onboard/manage-expenses', - ONBOARD_EXPENSIFY_CLASSIC: 'onboard/expensify-classic', - TEACHERS_UNITE: 'settings/teachersunite', I_KNOW_A_TEACHER: 'settings/teachersunite/i-know-a-teacher', I_AM_A_TEACHER: 'settings/teachersunite/i-am-a-teacher', @@ -479,10 +468,46 @@ const ROUTES = { route: 'settings/workspaces/:policyID/profile', getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile` as const, }, + WORKSPACE_ACCOUNTING: { + route: 'settings/workspaces/:policyID/accounting', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting` as const, + }, WORKSPACE_PROFILE_CURRENCY: { route: 'settings/workspaces/:policyID/profile/currency', getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/currency` as const, }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-online/company-card-expense-account-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/company-card-expense-account-select` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/invoice-account-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/invoice-account-select` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_PREFERRED_EXPORTER: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-online/preferred-exporter', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/preferred-exporter` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/out-of-pocket-expense', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/out-of-pocket-expense` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/out-of-pocket-expense/account-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/out-of-pocket-expense/account-select` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/out-of-pocket-expense/entity-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/out-of-pocket-expense/entity-select` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/date-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/date-select` as const, + }, WORKSPACE_PROFILE_NAME: { route: 'settings/workspaces/:policyID/profile/name', getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/name` as const, @@ -567,6 +592,18 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting` as const, }, + WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-online/advanced', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/advanced` as const, + }, + WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-online/account-selector', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/account-selector` as const, + }, + WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-online/invoice-account-selector', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/invoice-account-selector` as const, + }, WORKSPACE_CATEGORIES: { route: 'settings/workspaces/:policyID/categories', getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories` as const, @@ -654,7 +691,7 @@ const ROUTES = { WORKSPACE_OWNER_CHANGE_CHECK: { route: 'settings/workspaces/:policyID/change-owner/:accountID/:error', getRoute: (policyID: string, accountID: number, error: ValueOf) => - `settings/workspaces/${policyID}/change-owner/${accountID}/${error}` as const, + `settings/workspaces/${policyID}/change-owner/${accountID}/${error as string}` as const, }, WORKSPACE_TAX_CREATE: { route: 'settings/workspaces/:policyID/taxes/new', @@ -697,9 +734,10 @@ const ROUTES = { route: 'referral/:contentType', getRoute: (contentType: string, backTo?: string) => getUrlWithBackToParam(`referral/${contentType}`, backTo), }, - PROCESS_MONEY_REQUEST_HOLD: 'hold-request-educational', + PROCESS_MONEY_REQUEST_HOLD: 'hold-expense-educational', ONBOARDING_ROOT: 'onboarding', ONBOARDING_PERSONAL_DETAILS: 'onboarding/personal-details', + ONBOARDING_WORK: 'onboarding/work', ONBOARDING_PURPOSE: 'onboarding/purpose', WELCOME_VIDEO_ROOT: 'onboarding/welcome-video', @@ -740,9 +778,10 @@ const ROUTES = { */ const HYBRID_APP_ROUTES = { MONEY_REQUEST_CREATE: '/request/new/scan', + MONEY_REQUEST_SUBMIT_CREATE: '/submit/new/scan', } as const; -export {getUrlWithBackToParam, HYBRID_APP_ROUTES}; +export {HYBRID_APP_ROUTES, getUrlWithBackToParam}; export default ROUTES; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -762,4 +801,4 @@ type RouteIsPlainString = AssertTypesNotEqual strippedAmount.length && forwardDeletePressedRef.current; - setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length)); + setSelection(getNewSelection(selection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length)); onInputChange?.(strippedAmount); }, - [amountMaxLength, currentAmount, decimals, onInputChange], + [amountMaxLength, currentAmount, decimals, onInputChange, selection], ); // Modifies the amount to match the decimals for changed currency. @@ -225,6 +225,8 @@ function AmountForm( }} onKeyPress={textInputKeyPress} isCurrencyPressable={isCurrencyPressable} + style={[styles.iouAmountTextInput]} + containerStyle={[styles.iouAmountTextInputContainer]} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index abdef6707327..e0a494ec6fb1 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type {ForwardedRef} from 'react'; import type {NativeSyntheticEvent, StyleProp, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle, ViewStyle} from 'react-native'; -import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {TextSelection} from './Composer/types'; import TextInput from './TextInput'; @@ -31,21 +30,23 @@ type AmountTextInputProps = { /** Function to call to handle key presses in the text input */ onKeyPress?: (event: NativeSyntheticEvent) => void; + + /** Style for the TextInput container */ + containerStyle?: StyleProp; } & Pick; function AmountTextInput( - {formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, touchableInputWrapperStyle, onKeyPress, ...rest}: AmountTextInputProps, + {formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, touchableInputWrapperStyle, onKeyPress, containerStyle, ...rest}: AmountTextInputProps, ref: ForwardedRef, ) { - const styles = useThemeStyles(); return ( {}, onModalHide = () => {}, @@ -181,6 +185,7 @@ function AttachmentModal({ const nope = useSharedValue(false); const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); const isOverlayModalVisible = (isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!isReceiptAttachment && isAttachmentInvalid); + const iouType = useMemo(() => (isTrackExpenseAction ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT), [isTrackExpenseAction]); const [file, setFile] = useState( originalFileName @@ -422,7 +427,7 @@ function AttachmentModal({ Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.REQUEST, + iouType, transaction?.transactionID ?? '', report?.reportID ?? '', Navigation.getActiveRouteWithoutParams(), @@ -449,7 +454,7 @@ function AttachmentModal({ } return menuItems; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isReceiptAttachment, parentReport, parentReportActions, policy, transaction, file, sourceState]); + }, [isReceiptAttachment, parentReport, parentReportActions, policy, transaction, file, sourceState, iouType]); // There are a few things that shouldn't be set until we absolutely know if the file is a receipt or an attachment. // props.isReceiptAttachment will be null until its certain what the file is, in which case it will then be true|false. diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index ef4d7e3e4064..ad4cf023c096 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -5,7 +5,7 @@ import RNFetchBlob from 'react-native-blob-util'; import RNDocumentPicker from 'react-native-document-picker'; import type {DocumentPickerOptions, DocumentPickerResponse} from 'react-native-document-picker'; import {launchImageLibrary} from 'react-native-image-picker'; -import type {Asset, Callback, CameraOptions, ImageLibraryOptions, ImagePickerResponse} from 'react-native-image-picker'; +import type {Asset, Callback, CameraOptions, ImagePickerResponse} from 'react-native-image-picker'; import ImageSize from 'react-native-image-size'; import type {FileObject, ImagePickerResponse as FileResponse} from '@components/AttachmentModal'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -41,12 +41,11 @@ type Item = { * See https://github.com/react-native-image-picker/react-native-image-picker/#options * for ImagePicker configuration options */ -const imagePickerOptions: Partial = { +const imagePickerOptions = { includeBase64: false, saveToPhotos: false, selectionLimit: 1, includeExtra: false, - assetRepresentationMode: 'current', }; /** @@ -238,8 +237,9 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s const validateAndCompleteAttachmentSelection = useCallback( (fileData: FileResponse) => { // Check if the file dimensions indicate corruption - // The width/height for corrupt file is -1 on android native and 0 on ios native - if (!fileData.width || !fileData.height || (fileData.width <= 0 && fileData.height <= 0)) { + // The width/height for a corrupted file is -1 on android native and 0 on ios native + // We must check only numeric values because the width/height can be undefined for non-image files + if ((typeof fileData.width === 'number' && fileData.width <= 0) || (typeof fileData.height === 'number' && fileData.height <= 0)) { showImageCorruptionAlert(); return Promise.resolve(); } diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index ccd0f21626a0..a11fb2a01cc8 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -10,6 +10,7 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {useSuggestionsContext} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import viewForwardedRef from '@src/types/utils/viewForwardedRef'; @@ -39,6 +40,7 @@ function BaseAutoCompleteSuggestions( suggestions, isSuggestionPickerLarge, keyExtractor, + shouldBeDisplayedBelowParentContainer = false, }: AutoCompleteSuggestionsProps, ref: ForwardedRef, ) { @@ -47,6 +49,7 @@ function BaseAutoCompleteSuggestions( const StyleUtils = useStyleUtils(); const rowHeight = useSharedValue(0); const scrollRef = useRef>(null); + const {activeID} = useSuggestionsContext(); /** * Render a suggestion menu item component. */ @@ -68,7 +71,7 @@ function BaseAutoCompleteSuggestions( ); const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length; - const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value)); + const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value, shouldBeDisplayedBelowParentContainer, Boolean(activeID))); const estimatedListSize = useMemo( () => ({ height: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length, diff --git a/src/components/AutoCompleteSuggestions/index.native.tsx b/src/components/AutoCompleteSuggestions/index.native.tsx index fbfa7d953581..863e806b143e 100644 --- a/src/components/AutoCompleteSuggestions/index.native.tsx +++ b/src/components/AutoCompleteSuggestions/index.native.tsx @@ -1,11 +1,14 @@ import {Portal} from '@gorhom/portal'; import React from 'react'; +import {useSuggestionsContext} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; +import CONST from '@src/CONST'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import type {AutoCompleteSuggestionsProps} from './types'; function AutoCompleteSuggestions({measureParentContainer, ...props}: AutoCompleteSuggestionsProps) { + const {activeID} = useSuggestionsContext(); return ( - + {/* eslint-disable-next-line react/jsx-props-no-spreading */} {...props} /> diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index baca4011a177..66ea0de6f9f3 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -4,6 +4,7 @@ import {View} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import {measureHeightOfSuggestionsContainer} from '@libs/SuggestionUtils'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import type {AutoCompleteSuggestionsProps} from './types'; @@ -18,11 +19,13 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} const StyleUtils = useStyleUtils(); const containerRef = React.useRef(null); const {windowHeight, windowWidth} = useWindowDimensions(); + const suggestionsContainerHeight = measureHeightOfSuggestionsContainer(props.suggestions.length, props.isSuggestionPickerLarge); const [{width, left, bottom}, setContainerState] = React.useState({ width: 0, left: 0, bottom: 0, }); + const [shouldShowBelowContainer, setShouldShowBelowContainer] = React.useState(false); React.useEffect(() => { const container = containerRef.current; if (!container) { @@ -41,13 +44,19 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} if (!measureParentContainer) { return; } - measureParentContainer((x, y, w) => setContainerState({left: x, bottom: windowHeight - y, width: w})); - }, [measureParentContainer, windowHeight, windowWidth]); + + measureParentContainer((x, y, w, h) => { + const currentBottom = y < suggestionsContainerHeight ? windowHeight - y - suggestionsContainerHeight - h : windowHeight - y; + setShouldShowBelowContainer(y < suggestionsContainerHeight); + setContainerState({left: x, bottom: currentBottom, width: w}); + }); + }, [measureParentContainer, windowHeight, windowWidth, suggestionsContainerHeight]); const componentToRender = ( // eslint-disable-next-line react/jsx-props-no-spreading {...props} + shouldBeDisplayedBelowParentContainer={shouldShowBelowContainer} ref={containerRef} /> ); diff --git a/src/components/AutoCompleteSuggestions/types.ts b/src/components/AutoCompleteSuggestions/types.ts index 61d614dcf2e4..d9824db1988d 100644 --- a/src/components/AutoCompleteSuggestions/types.ts +++ b/src/components/AutoCompleteSuggestions/types.ts @@ -1,6 +1,6 @@ import type {ReactElement} from 'react'; -type MeasureParentContainerCallback = (x: number, y: number, width: number) => void; +type MeasureParentContainerCallback = (x: number, y: number, width: number, height: number) => void; type RenderSuggestionMenuItemProps = { item: TSuggestion; @@ -33,6 +33,9 @@ type AutoCompleteSuggestionsProps = { /** Meaures the parent container's position and dimensions. */ measureParentContainer?: (callback: MeasureParentContainerCallback) => void; + + /** Whether suggestion should be displayed below the parent container or not */ + shouldBeDisplayedBelowParentContainer?: boolean; }; export type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps}; diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 358f5333bfba..bf48894beaab 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -90,7 +90,7 @@ function Avatar({ if (isWorkspace) { iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(name); } else if (useFallBackAvatar) { - iconColors = StyleUtils.getBackgroundColorAndFill(theme.border, theme.icon); + iconColors = StyleUtils.getBackgroundColorAndFill(theme.buttonHoveredBG, theme.icon); } else { iconColors = null; } diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index b23d02cd7685..75a00e802e98 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -2,7 +2,6 @@ import React, {useCallback} from 'react'; import type {GestureResponderEvent, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -61,15 +60,21 @@ function Badge({ }: BadgeProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const theme = useTheme(); - const textColorStyles = success || error ? styles.textWhite : undefined; const Wrapper = pressable ? PressableWithoutFeedback : View; const isDeleted = style && Array.isArray(style) ? style.includes(styles.offlineFeedback.deleted) : false; + const iconColor = StyleUtils.getIconColorStyle(success, error); + const wrapperStyles: (state: PressableStateCallbackType) => StyleProp = useCallback( - ({pressed}) => [styles.badge, styles.ml2, StyleUtils.getBadgeColorStyle(success, error, pressed, environment === CONST.ENVIRONMENT.ADHOC), badgeStyles], - [styles.badge, styles.ml2, StyleUtils, success, error, environment, badgeStyles], + ({pressed}) => [ + styles.defaultBadge, + styles.alignSelfCenter, + styles.ml2, + StyleUtils.getBadgeColorStyle(success, error, pressed, environment === CONST.ENVIRONMENT.ADHOC), + badgeStyles, + ], + [styles.defaultBadge, styles.alignSelfCenter, styles.ml2, StyleUtils, success, error, environment, badgeStyles], ); return ( @@ -87,12 +92,12 @@ function Badge({ width={variables.iconSizeExtraSmall} height={variables.iconSizeExtraSmall} src={icon} - fill={theme.icon} + fill={iconColor} /> )} {text} diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx index d01519a13513..4704ca6c6eec 100644 --- a/src/components/BigNumberPad.tsx +++ b/src/components/BigNumberPad.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -33,6 +33,11 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i const styles = useThemeStyles(); const [timer, setTimer] = useState(null); const {isExtraSmallScreenHeight} = useWindowDimensions(); + const numberPressedRef = useRef(numberPressed); + + useEffect(() => { + numberPressedRef.current = numberPressed; + }, [numberPressed]); /** * Handle long press key on number pad. @@ -46,7 +51,7 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i longPressHandlerStateChanged(true); const newTimer = setInterval(() => { - numberPressed(key); + numberPressedRef.current?.(key); }, 100); setTimer(newTimer); diff --git a/src/components/EmojiSuggestions.tsx b/src/components/EmojiSuggestions.tsx index 1c0306741048..c95288f43164 100644 --- a/src/components/EmojiSuggestions.tsx +++ b/src/components/EmojiSuggestions.tsx @@ -9,7 +9,7 @@ import getStyledTextArray from '@libs/GetStyledTextArray'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; import Text from './Text'; -type MeasureParentContainerCallback = (x: number, y: number, width: number) => void; +type MeasureParentContainerCallback = (x: number, y: number, width: number, height: number) => void; type EmojiSuggestionsProps = { /** The index of the highlighted emoji */ diff --git a/src/components/EnvironmentBadge.tsx b/src/components/EnvironmentBadge.tsx index 9a0854b815ef..5122b7f461b0 100644 --- a/src/components/EnvironmentBadge.tsx +++ b/src/components/EnvironmentBadge.tsx @@ -1,5 +1,6 @@ import React from 'react'; import useEnvironment from '@hooks/useEnvironment'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Environment from '@libs/Environment/Environment'; import CONST from '@src/CONST'; @@ -15,8 +16,15 @@ const ENVIRONMENT_SHORT_FORM = { function EnvironmentBadge() { const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {environment, isProduction} = useEnvironment(); + const adhoc = environment === CONST.ENVIRONMENT.ADHOC; + const success = environment === CONST.ENVIRONMENT.STAGING; + const error = environment !== CONST.ENVIRONMENT.STAGING && environment !== CONST.ENVIRONMENT.ADHOC; + + const badgeEnviromentStyle = StyleUtils.getEnvironmentBadgeStyle(success, error, adhoc); + // If we are on production, don't show any badge if (isProduction) { return null; @@ -26,10 +34,10 @@ function EnvironmentBadge() { return ( { - // eslint-disable-next-line rulesdir/prefer-underscore-method if (Object.keys(props).includes('fill')) { // eslint-disable-next-line no-param-reassign props.fill = {type: 0, payload: processColor(props.fill)}; } - // eslint-disable-next-line rulesdir/prefer-underscore-method if (Object.keys(props).includes('stroke')) { // eslint-disable-next-line no-param-reassign props.stroke = {type: 0, payload: processColor(props.stroke)}; diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 902a96b1bcaf..1ba633e5c9fe 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -102,7 +102,7 @@ function FormWrapper({ {children} {isSubmitButtonVisible && ( @@ -123,26 +123,27 @@ function FormWrapper({ ), [ - children, - enabledWhenOffline, - errorMessage, - errors, - footerContent, formID, - formState?.errorFields, - formState?.isLoading, - isSubmitActionDangerous, - isSubmitButtonVisible, - onSubmit, style, - styles.flex1, + styles.pb5, styles.mh0, styles.mt5, - submitButtonStyles, - submitFlexEnabled, + styles.flex1, + children, + isSubmitButtonVisible, submitButtonText, + errors, + formState?.errorFields, + formState?.isLoading, shouldHideFixErrorsAlert, + errorMessage, + onSubmit, + footerContent, onFixTheErrorsLinkPressed, + submitFlexEnabled, + submitButtonStyles, + enabledWhenOffline, + isSubmitActionDangerous, disablePressOnEnter, ], ); diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index f4e5bf4834e0..e6cecdc0d5ec 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -1,11 +1,40 @@ -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {withOnyx} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import BaseImage from './BaseImage'; -import type {ImageOnyxProps, ImageOwnProps, ImageProps} from './types'; +import type {ImageOnLoadEvent, ImageOnyxProps, ImageOwnProps, ImageProps} from './types'; -function Image({source: propsSource, isAuthTokenRequired = false, session, ...forwardedProps}: ImageProps) { +function Image({source: propsSource, isAuthTokenRequired = false, session, onLoad, objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL, style, ...forwardedProps}: ImageProps) { + const [aspectRatio, setAspectRatio] = useState(null); + const isObjectPositionTop = objectPosition === CONST.IMAGE_OBJECT_POSITION.TOP; + + const updateAspectRatio = useCallback( + (width: number, height: number) => { + if (!isObjectPositionTop) { + return; + } + + if (width > height) { + setAspectRatio(1); + return; + } + + setAspectRatio(height ? width / height : 'auto'); + }, + [isObjectPositionTop], + ); + + const handleLoad = useCallback( + (event: ImageOnLoadEvent) => { + const {width, height} = event.nativeEvent; + + onLoad?.(event); + + updateAspectRatio(width, height); + }, + [onLoad, updateAspectRatio], + ); /** * Check if the image source is a URL - if so the `encryptedAuthToken` is appended * to the source. @@ -34,6 +63,8 @@ function Image({source: propsSource, isAuthTokenRequired = false, session, ...fo ); diff --git a/src/components/Image/types.ts b/src/components/Image/types.ts index 2a5fcbb19324..27964d8a6764 100644 --- a/src/components/Image/types.ts +++ b/src/components/Image/types.ts @@ -1,10 +1,14 @@ import type {ImageSource} from 'expo-image'; import type {ImageRequireSource, ImageResizeMode, ImageStyle, ImageURISource, StyleProp} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; import type {Session} from '@src/types/onyx'; type ExpoImageSource = ImageSource | number | ImageSource[]; +type ImageObjectPosition = ValueOf; + type ImageOnyxProps = { /** Session info for the currently logged in user. */ session: OnyxEntry; @@ -23,12 +27,12 @@ type BaseImageProps = { /** Event for when the image is fully loaded and returns the natural dimensions of the image */ onLoad?: (event: ImageOnLoadEvent) => void; -}; -type ImageOwnProps = BaseImageProps & { /** Styles for the Image */ style?: StyleProp; +}; +type ImageOwnProps = BaseImageProps & { /** Should an auth token be included in the image request */ isAuthTokenRequired?: boolean; @@ -46,8 +50,11 @@ type ImageOwnProps = BaseImageProps & { /** Progress events while the image is downloading */ onProgress?: () => void; + + /** The object position of image */ + objectPosition?: ImageObjectPosition; }; type ImageProps = ImageOnyxProps & ImageOwnProps; -export type {BaseImageProps, ImageOwnProps, ImageOnyxProps, ImageProps, ExpoImageSource, ImageOnLoadEvent}; +export type {BaseImageProps, ImageOwnProps, ImageOnyxProps, ImageProps, ExpoImageSource, ImageOnLoadEvent, ImageObjectPosition}; diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index f2dcc2cc3422..eac5c676370b 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -5,9 +5,11 @@ import {View} from 'react-native'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; +import CONST from '@src/CONST'; import FullscreenLoadingIndicator from './FullscreenLoadingIndicator'; import Image from './Image'; import RESIZE_MODES from './Image/resizeModes'; +import type {ImageObjectPosition} from './Image/types'; type OnMeasure = (args: {width: number; height: number}) => void; @@ -32,6 +34,9 @@ type ImageWithSizeCalculationProps = { /** Whether the image requires an authToken */ isAuthTokenRequired: boolean; + + /** The object position of image */ + objectPosition?: ImageObjectPosition; }; /** @@ -40,7 +45,7 @@ type ImageWithSizeCalculationProps = { * performing some calculation on a network image after fetching dimensions so * it can be appropriately resized. */ -function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthTokenRequired}: ImageWithSizeCalculationProps) { +function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthTokenRequired, objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL}: ImageWithSizeCalculationProps) { const styles = useThemeStyles(); const isLoadedRef = useRef(null); const [isImageCached, setIsImageCached] = useState(true); @@ -101,6 +106,7 @@ function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthT }} onError={onError} onLoad={imageLoadedSuccessfully} + objectPosition={objectPosition} /> {isLoading && !isImageCached && }
diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index f23c8db97f47..1afadd8ea856 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -603,14 +603,7 @@ function MenuItem( {badgeText && ( )} {/* Since subtitle can be of type number, we should allow 0 to be shown */} diff --git a/src/components/Modal/modalPropTypes.js b/src/components/Modal/modalPropTypes.js deleted file mode 100644 index 84e610b694e4..000000000000 --- a/src/components/Modal/modalPropTypes.js +++ /dev/null @@ -1,89 +0,0 @@ -import PropTypes from 'prop-types'; -import _ from 'underscore'; -import {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import stylePropTypes from '@styles/stylePropTypes'; -import CONST from '@src/CONST'; - -const propTypes = { - /** Decides whether the modal should cover fullscreen. FullScreen modal has backdrop */ - fullscreen: PropTypes.bool, - - /** Should we close modal on outside click */ - shouldCloseOnOutsideClick: PropTypes.bool, - - /** Should we announce the Modal visibility changes? */ - shouldSetModalVisibility: PropTypes.bool, - - /** Callback method fired when the user requests to close the modal */ - onClose: PropTypes.func.isRequired, - - /** State that determines whether to display the modal or not */ - isVisible: PropTypes.bool.isRequired, - - /** Modal contents */ - children: PropTypes.node.isRequired, - - /** Callback method fired when the user requests to submit the modal content. */ - onSubmit: PropTypes.func, - - /** Callback method fired when the modal is hidden */ - onModalHide: PropTypes.func, - - /** Callback method fired when the modal is shown */ - onModalShow: PropTypes.func, - - /** Style of modal to display */ - type: PropTypes.oneOf(_.values(CONST.MODAL.MODAL_TYPE)), - - /** A react-native-animatable animation definition for the modal display animation. */ - animationIn: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - - /** A react-native-animatable animation definition for the modal hide animation. */ - animationOut: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - - /** The anchor position of a popover modal. Has no effect on other modal types. */ - popoverAnchorPosition: PropTypes.shape({ - top: PropTypes.number, - right: PropTypes.number, - bottom: PropTypes.number, - left: PropTypes.number, - }), - - /** Modal container styles */ - innerContainerStyle: stylePropTypes, - - /** Whether the modal should go under the system statusbar */ - statusBarTranslucent: PropTypes.bool, - - /** Whether the modal should avoid the keyboard */ - avoidKeyboard: PropTypes.bool, - - /** - * Whether the modal should hide its content while animating. On iOS, set to true - * if `useNativeDriver` is also true, to avoid flashes in the UI. - * - * See: https://github.com/react-native-modal/react-native-modal/pull/116 - * */ - hideModalContentWhileAnimating: PropTypes.bool, - - ...windowDimensionsPropTypes, -}; - -const defaultProps = { - fullscreen: true, - shouldCloseOnOutsideClick: false, - shouldSetModalVisibility: true, - onSubmit: null, - type: '', - onModalHide: () => {}, - onModalShow: () => {}, - animationIn: null, - animationOut: null, - popoverAnchorPosition: {}, - innerContainerStyle: {}, - statusBarTranslucent: true, - avoidKeyboard: false, - hideModalContentWhileAnimating: false, -}; - -export {propTypes, defaultProps}; diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx new file mode 100644 index 000000000000..fcc156db4a96 --- /dev/null +++ b/src/components/MoneyRequestAmountInput.tsx @@ -0,0 +1,251 @@ +import type {ForwardedRef} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import type {NativeSyntheticEvent, StyleProp, TextInputSelectionChangeEventData, TextStyle, ViewStyle} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import * as Browser from '@libs/Browser'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import getOperatingSystem from '@libs/getOperatingSystem'; +import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import CONST from '@src/CONST'; +import type {BaseTextInputRef} from './TextInput/BaseTextInput/types'; +import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; + +type CurrentMoney = {amount: string; currency: string}; + +type MoneyRequestAmountInputRef = { + setNewAmount: (amountValue: string) => void; + changeSelection: (newSelection: Selection) => void; + changeAmount: (newAmount: string) => void; + getAmount: () => string; + getSelection: () => Selection; +}; + +type MoneyRequestAmountInputProps = { + /** IOU amount saved in Onyx */ + amount?: number; + + /** Currency chosen by user or saved in Onyx */ + currency?: string; + + /** Whether the currency symbol is pressable */ + isCurrencyPressable?: boolean; + + /** Fired when back button pressed, navigates to currency selection page */ + onCurrencyButtonPress?: () => void; + + /** Function to call when the amount changes */ + onAmountChange?: (amount: string) => void; + + /** Whether to update the selection */ + shouldUpdateSelection?: boolean; + + /** Style for the input */ + inputStyle?: StyleProp; + + /** Style for the container */ + containerStyle?: StyleProp; + + /** Reference to moneyRequestAmountInputRef */ + moneyRequestAmountInputRef?: ForwardedRef; + + /** Character to be shown before the amount */ + prefixCharacter?: string; + + /** Whether to hide the currency symbol */ + hideCurrencySymbol?: boolean; + + /** Style for the prefix */ + prefixStyle?: StyleProp; + + /** Style for the prefix container */ + prefixContainerStyle?: StyleProp; + + /** Style for the touchable input wrapper */ + touchableInputWrapperStyle?: StyleProp; +}; + +type Selection = { + start: number; + end: number; +}; + +/** + * Returns the new selection object based on the updated amount's length + */ +const getNewSelection = (oldSelection: Selection, prevLength: number, newLength: number): Selection => { + const cursorPosition = oldSelection.end + (newLength - prevLength); + return {start: cursorPosition, end: cursorPosition}; +}; + +function MoneyRequestAmountInput( + { + amount = 0, + currency = CONST.CURRENCY.USD, + isCurrencyPressable = true, + onCurrencyButtonPress, + onAmountChange, + prefixCharacter = '', + hideCurrencySymbol = false, + shouldUpdateSelection = true, + moneyRequestAmountInputRef, + ...props + }: MoneyRequestAmountInputProps, + forwardedRef: ForwardedRef, +) { + const {toLocaleDigit, numberFormat} = useLocalize(); + + const textInput = useRef(null); + + const decimals = CurrencyUtils.getCurrencyDecimals(currency); + const selectedAmountAsString = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : ''; + + const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString); + + const [selection, setSelection] = useState({ + start: selectedAmountAsString.length, + end: selectedAmountAsString.length, + }); + + const forwardDeletePressedRef = useRef(false); + + /** + * Sets the selection and the amount accordingly to the value passed to the input + * @param {String} newAmount - Changed amount from user input + */ + const setNewAmount = useCallback( + (newAmount: string) => { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); + // Use a shallow copy of selection to trigger setSelection + // More info: https://github.com/Expensify/App/issues/16385 + if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces, decimals)) { + setSelection((prevSelection) => ({...prevSelection})); + return; + } + + // setCurrentAmount contains another setState(setSelection) making it error-prone since it is leading to setSelection being called twice for a single setCurrentAmount call. This solution introducing the hasSelectionBeenSet flag was chosen for its simplicity and lower risk of future errors https://github.com/Expensify/App/issues/23300#issuecomment-1766314724. + + let hasSelectionBeenSet = false; + setCurrentAmount((prevAmount) => { + const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); + const isForwardDelete = prevAmount.length > strippedAmount.length && forwardDeletePressedRef.current; + if (!hasSelectionBeenSet) { + hasSelectionBeenSet = true; + setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : prevAmount.length, strippedAmount.length)); + } + onAmountChange?.(strippedAmount); + return strippedAmount; + }); + }, + [decimals, onAmountChange], + ); + + useImperativeHandle(moneyRequestAmountInputRef, () => ({ + setNewAmount(amountValue: string) { + setNewAmount(amountValue); + }, + changeSelection(newSelection: Selection) { + setSelection(newSelection); + }, + changeAmount(newAmount: string) { + setCurrentAmount(newAmount); + }, + getAmount() { + return currentAmount; + }, + getSelection() { + return selection; + }, + })); + + useEffect(() => { + if (!currency || typeof amount !== 'number') { + return; + } + const frontendAmount = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : ''; + setCurrentAmount(frontendAmount); + setSelection({ + start: frontendAmount.length, + end: frontendAmount.length, + }); + // we want to re-initialize the state only when the amount changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [amount]); + + // Modifies the amount to match the decimals for changed currency. + useEffect(() => { + // If the changed currency supports decimals, we can return + if (MoneyRequestUtils.validateAmount(currentAmount, decimals)) { + return; + } + + // If the changed currency doesn't support decimals, we can strip the decimals + setNewAmount(MoneyRequestUtils.stripDecimalsFromAmount(currentAmount)); + + // we want to update only when decimals change (setNewAmount also changes when decimals change). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [setNewAmount]); + + /** + * Input handler to check for a forward-delete key (or keyboard shortcut) press. + */ + const textInputKeyPress = ({nativeEvent}: NativeSyntheticEvent) => { + const key = nativeEvent?.key.toLowerCase(); + if (Browser.isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) { + // Optimistically anticipate forward-delete on iOS Safari (in cases where the Mac Accessiblity keyboard is being + // used for input). If the Control-D shortcut doesn't get sent, the ref will still be reset on the next key press. + forwardDeletePressedRef.current = true; + return; + } + // Control-D on Mac is a keyboard shortcut for forward-delete. See https://support.apple.com/en-us/HT201236 for Mac keyboard shortcuts. + // Also check for the keyboard shortcut on iOS in cases where a hardware keyboard may be connected to the device. + const operatingSystem = getOperatingSystem(); + forwardDeletePressedRef.current = key === 'delete' || ((operatingSystem === CONST.OS.MAC_OS || operatingSystem === CONST.OS.IOS) && nativeEvent?.ctrlKey && key === 'd'); + }; + + const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); + + return ( + { + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef?.current) { + // eslint-disable-next-line no-param-reassign + forwardedRef.current = ref; + } + textInput.current = ref; + }} + selectedCurrencyCode={currency} + selection={selection} + onSelectionChange={(e: NativeSyntheticEvent) => { + if (!shouldUpdateSelection) { + return; + } + const maxSelection = formattedAmount.length; + const start = Math.min(e.nativeEvent.selection.start, maxSelection); + const end = Math.min(e.nativeEvent.selection.end, maxSelection); + setSelection({start, end}); + }} + onKeyPress={textInputKeyPress} + hideCurrencySymbol={hideCurrencySymbol} + prefixCharacter={prefixCharacter} + isCurrencyPressable={isCurrencyPressable} + style={props.inputStyle} + containerStyle={props.containerStyle} + prefixStyle={props.prefixStyle} + prefixContainerStyle={props.prefixContainerStyle} + touchableInputWrapperStyle={props.touchableInputWrapperStyle} + /> + ); +} + +MoneyRequestAmountInput.displayName = 'MoneyRequestAmountInput'; + +export default React.forwardRef(MoneyRequestAmountInput); +export type {CurrentMoney, MoneyRequestAmountInputRef}; diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 4b3e4096484f..552b849e74a3 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1,43 +1,50 @@ import {useIsFocused} from '@react-navigation/native'; import {format} from 'date-fns'; +import Str from 'expensify-common/lib/str'; import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {StyleProp, ViewStyle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; +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 type {DefaultMileageRate} from '@libs/DistanceRequestUtils'; import DistanceRequestUtils 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'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; +import {isTaxTrackingEnabled} from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; +import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import * as IOU from '@userActions/IOU'; +import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -import type {ReceiptSource} from '@src/types/onyx/Transaction'; import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; import type {DropdownOption} from './ButtonWithDropdownMenu/types'; import ConfirmedRoute from './ConfirmedRoute'; +import ConfirmModal from './ConfirmModal'; import FormHelpMessage from './FormHelpMessage'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import OptionsSelector from './OptionsSelector'; +import PDFThumbnail from './PDFThumbnail'; import ReceiptEmptyState from './ReceiptEmptyState'; import ReceiptImage from './ReceiptImage'; import SettlementButton from './SettlementButton'; @@ -45,8 +52,6 @@ import ShowMoreButton from './ShowMoreButton'; import Switch from './Switch'; import Text from './Text'; -type IouType = ValueOf; - type MoneyRequestConfirmationListOnyxProps = { /** Collection of categories attached to a policy */ policyCategories: OnyxEntry; @@ -57,21 +62,25 @@ type MoneyRequestConfirmationListOnyxProps = { /** The policy of the report */ policy: OnyxEntry; - /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ - iou: OnyxEntry; - /** The session of the logged in user */ session: OnyxEntry; - /** Unit and rate used for if the money request is a distance request */ - mileageRate: OnyxEntry; + /** Unit and rate used for if the expense is a distance expense */ + mileageRates: OnyxEntry>; + + /** Mileage rate default for the policy */ + defaultMileageRate: OnyxEntry; + + /** Last selected distance rates */ + lastSelectedDistanceRates: OnyxEntry>; }; + type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & { /** Callback to inform parent modal of success */ - onConfirm?: (selectedParticipants: Array) => void; + onConfirm?: (selectedParticipants: Participant[]) => void; - /** Callback to parent modal to send money */ - onSendMoney?: (paymentMethod: IouType | PaymentMethodType | undefined) => void; + /** Callback to parent modal to pay someone */ + onSendMoney?: (paymentMethod: PaymentMethodType | undefined) => void; /** Callback to inform a participant is selected */ onSelectParticipant?: (option: Participant) => void; @@ -89,7 +98,7 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & iouCurrencyCode?: string; /** IOU type */ - iouType?: IouType; + iouType?: Exclude; /** IOU date */ iouCreated?: string; @@ -100,9 +109,6 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** IOU Category */ iouCategory?: string; - /** IOU Tag */ - iouTag?: string; - /** IOU isBillable */ iouIsBillable?: boolean; @@ -110,9 +116,9 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & onToggleBillable?: (isOn: boolean) => void; /** Selected participants from MoneyRequestModal with login / accountID */ - selectedParticipants: Array; + selectedParticipants: Participant[]; - /** Payee of the money request with login */ + /** Payee of the expense with login */ payeePersonalDetails?: OnyxEntry; /** Can the participants be modified or not */ @@ -131,7 +137,7 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & reportID?: string; /** File path of the receipt */ - receiptPath?: ReceiptSource; + receiptPath?: string; /** File name of the receipt */ receiptFilename?: string; @@ -139,19 +145,16 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** List styles for OptionsSelector */ listStyles?: StyleProp; - /** ID of the transaction that represents the money request */ - transactionID?: string; - - /** Transaction that represents the money request */ + /** Transaction that represents the expense */ transaction?: OnyxEntry; - /** Whether the money request is a distance request */ + /** Whether the expense is a distance expense */ isDistanceRequest?: boolean; - /** Whether the money request is a scan request */ + /** Whether the expense is a scan expense */ isScanRequest?: boolean; - /** Whether we're editing a split bill */ + /** Whether we're editing a split expense */ isEditingSplitBill?: boolean; /** Whether we should show the amount, date, and merchant fields. */ @@ -165,6 +168,14 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** The ID of the report action */ reportActionID?: string; + + /** The action to take */ + action?: IOUAction; +}; + +const getTaxAmount = (transaction: OnyxEntry, defaultTaxValue: string) => { + const percentage = (transaction?.taxRate ? transaction?.taxRate?.data?.value : defaultTaxValue) ?? ''; + return TransactionUtils.calculateTaxAmount(percentage, transaction?.amount ?? 0); }; function MoneyRequestConfirmationList({ @@ -172,11 +183,11 @@ function MoneyRequestConfirmationList({ onSendMoney, onConfirm, onSelectParticipant, - iouType = CONST.IOU.TYPE.REQUEST, + iouType = CONST.IOU.TYPE.SUBMIT, isScanRequest = false, iouAmount, policyCategories, - mileageRate, + mileageRates, isDistanceRequest = false, policy, isPolicyExpenseChat = false, @@ -189,19 +200,6 @@ function MoneyRequestConfirmationList({ hasMultipleParticipants, selectedParticipants: selectedParticipantsProp, payeePersonalDetails: payeePersonalDetailsProp, - iou = { - id: '', - amount: 0, - currency: CONST.CURRENCY.USD, - comment: '', - merchant: '', - category: '', - tag: '', - billable: false, - created: '', - participants: [], - receiptPath: '', - }, canModifyParticipants: canModifyParticipantsProp = false, session, isReadOnly = false, @@ -215,69 +213,89 @@ function MoneyRequestConfirmationList({ iouCreated, iouIsBillable = false, onToggleBillable, - iouTag = '', - transactionID = '', hasSmartScanFailed, reportActionID, + defaultMileageRate, + lastSelectedDistanceRates, + action = CONST.IOU.ACTION.CREATE, }: MoneyRequestConfirmationListProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate, toLocaleDigit} = useLocalize(); - const {canUseViolations} = usePermissions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {canUseP2PDistanceRequests, canUseViolations} = usePermissions(iouType); + const {isOffline} = useNetwork(); - const isTypeRequest = iouType === CONST.IOU.TYPE.REQUEST; - const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; - const isTypeSend = iouType === CONST.IOU.TYPE.SEND; + const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT; + const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; + const isTypeSend = iouType === CONST.IOU.TYPE.PAY; + const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK; - const isSplitWithScan = isSplitBill && isScanRequest; + const transactionID = transaction?.transactionID ?? ''; + const customUnitRateID = TransactionUtils.getRateID(transaction) ?? ''; - 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; + 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 ?? {}))); + // A flag and a toggler for showing the rest of the form fields const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false); - // Do not hide fields in case of send money request - const shouldShowAllFields = !!isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || isEditingSplitBill; + // Do not hide fields in case of paying someone + const shouldShowAllFields = !!isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || !!isEditingSplitBill; // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item - const shouldShowDate = shouldShowAllFields && !isTypeSend && !isSplitWithScan; - const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !isDistanceRequest && !isSplitWithScan; + const shouldShowDate = (shouldShowSmartScanFields || isDistanceRequest) && !isTypeSend; + const shouldShowMerchant = shouldShowSmartScanFields && !isDistanceRequest && !isTypeSend; const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); // A flag for showing the tags field - const shouldShowTags = isPolicyExpenseChat && (!!iouTag || OptionsListUtils.hasEnabledTags(policyTagLists)); + const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists), [isPolicyExpenseChat, policyTagLists]); - // A flag for showing tax fields - tax rate and tax amount - const shouldShowTax = isPolicyExpenseChat && (policy?.tax?.trackingEnabled ?? policy?.isTaxTrackingEnabled); + // A flag for showing tax rate + const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy); // A flag for showing the billable field - const shouldShowBillable = !(policy?.disabledFields?.defaultBillable ?? true); - - const hasRoute = TransactionUtils.hasRoute(transaction); - const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate); + const shouldShowBillable = policy?.disabledFields?.defaultBillable === false; + const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); + 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); + const taxRateTitle = taxRates && transaction ? TransactionUtils.getDefaultTaxName(taxRates, transaction) : ''; - const defaultTaxKey = taxRates?.defaultExternalID; - const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) ${CONST.DOT_SEPARATOR} ${translate('common.default')}`) ?? ''; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing is not working when a left hand side value is '' - const taxRateTitle = transaction?.taxRate?.text || defaultTaxName; + const previousTransactionAmount = usePrevious(transaction?.amount); const isFocused = useIsFocused(); const [formError, setFormError] = useState(''); @@ -285,6 +303,14 @@ function MoneyRequestConfirmationList({ const [didConfirm, setDidConfirm] = useState(false); const [didConfirmSplit, setDidConfirmSplit] = useState(false); + const [merchantError, setMerchantError] = useState(false); + + const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + + const navigateBack = () => { + Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)); + }; + const shouldDisplayFieldError: boolean = useMemo(() => { if (!isEditingSplitBill) { return false; @@ -294,60 +320,89 @@ function MoneyRequestConfirmationList({ }, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]); const isMerchantEmpty = !iouMerchant || iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; - const shouldDisplayMerchantError = isPolicyExpenseChat && shouldDisplayFieldError && isMerchantEmpty; + const isMerchantRequired = isPolicyExpenseChat && !isScanRequest && shouldShowMerchant; + + const isCategoryRequired = canUseViolations && !!policy?.requiresCategory; useEffect(() => { - if (shouldDisplayFieldError && didConfirmSplit) { - setFormError('iou.error.genericSmartscanFailureMessage'); + if ((!isMerchantRequired && isMerchantEmpty) || !merchantError) { return; } + if (!isMerchantEmpty && merchantError) { + setMerchantError(false); + if (formError === 'iou.error.invalidMerchant') { + setFormError(''); + } + } + }, [formError, isMerchantEmpty, merchantError, isMerchantRequired]); + + useEffect(() => { if (shouldDisplayFieldError && hasSmartScanFailed) { setFormError('iou.receiptScanningFailed'); return; } + if (shouldDisplayFieldError && didConfirmSplit) { + setFormError('iou.error.genericSmartscanFailureMessage'); + return; + } + if (merchantError) { + setFormError('iou.error.invalidMerchant'); + return; + } // reset the form error whenever the screen gains or loses focus setFormError(''); - }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); + }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isMerchantRequired, merchantError]); useEffect(() => { if (!shouldCalculateDistanceAmount) { return; } - const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate ?? 0); - IOU.setMoneyRequestAmount(amount); - }, [shouldCalculateDistanceAmount, distance, rate, unit]); + 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]); + + // Calculate and set tax amount in transaction draft + useEffect(() => { + const taxAmount = getTaxAmount(transaction, taxRates?.defaultValue ?? '').toString(); + const amountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(taxAmount)); + + if (transaction?.taxAmount && previousTransactionAmount === transaction?.amount) { + return IOU.setMoneyRequestTaxAmount(transaction?.transactionID, transaction?.taxAmount, true); + } + + IOU.setMoneyRequestTaxAmount(transactionID, amountInSmallestCurrencyUnits, true); + }, [taxRates?.defaultValue, transaction, transactionID, previousTransactionAmount]); /** * Returns the participants with amount */ const getParticipantsWithAmount = useCallback( - (participantsList: Array): Array => { - const calculatedIouAmount = IOUUtils.calculateAmount(participantsList.length, iouAmount, iouCurrencyCode ?? ''); - return OptionsListUtils.getIOUConfirmationOptionsFromParticipants( - participantsList, - iouAmount > 0 ? CurrencyUtils.convertToDisplayString(calculatedIouAmount, iouCurrencyCode) : '', - ); + (participantsList: Participant[]) => { + const amount = IOUUtils.calculateAmount(participantsList.length, iouAmount, iouCurrencyCode ?? ''); + return OptionsListUtils.getIOUConfirmationOptionsFromParticipants(participantsList, amount > 0 ? CurrencyUtils.convertToDisplayString(amount, iouCurrencyCode) : ''); }, [iouAmount, iouCurrencyCode], ); - // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again + // If completing a split expense fails, set didConfirm to false to allow the user to edit the fields again if (isEditingSplitBill && didConfirm) { setDidConfirm(false); } - const splitOrRequestOptions: Array> = useMemo(() => { + const splitOrRequestOptions: Array> = useMemo(() => { let text; - if (isSplitBill && iouAmount === 0) { - text = translate('iou.split'); - } else if ((!!receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { - text = translate('iou.expense'); + if (isTypeTrackExpense) { + text = translate('iou.trackExpense'); + } else if (isTypeSplit && iouAmount === 0) { + text = translate('iou.splitExpense'); + } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { + text = translate('iou.submitExpense'); if (iouAmount !== 0) { text = translate('iou.submitAmount', {amount: formattedAmount}); } } else { - const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.submitAmount'; + const translationKey = isTypeSplit ? 'iou.splitAmount' : 'iou.submitAmount'; text = translate(translationKey, {amount: formattedAmount}); } return [ @@ -356,17 +411,13 @@ function MoneyRequestConfirmationList({ value: iouType, }, ]; - }, [isSplitBill, isTypeRequest, iouType, iouAmount, receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]); + }, [isTypeTrackExpense, isTypeSplit, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount]); - const selectedParticipants: Array = useMemo( - () => selectedParticipantsProp.filter((participant) => participant.selected), - [selectedParticipantsProp], - ); + const selectedParticipants = useMemo(() => selectedParticipantsProp.filter((participant) => participant.selected), [selectedParticipantsProp]); const payeePersonalDetails = useMemo(() => payeePersonalDetailsProp ?? currentUserPersonalDetails, [payeePersonalDetailsProp, currentUserPersonalDetails]); const canModifyParticipants = !isReadOnly && canModifyParticipantsProp && hasMultipleParticipants; const shouldDisablePaidBySection = canModifyParticipants; - - const optionSelectorSections: OptionsListUtils.CategorySection[] = useMemo(() => { + const optionSelectorSections = useMemo(() => { const sections = []; const unselectedParticipants = selectedParticipantsProp.filter((participant) => !participant.selected); if (hasMultipleParticipants) { @@ -400,9 +451,9 @@ function MoneyRequestConfirmationList({ }, ); } else { - const formattedSelectedParticipants = selectedParticipantsProp.map((participant) => ({ + const formattedSelectedParticipants = selectedParticipants.map((participant) => ({ ...participant, - isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), + isDisabled: !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), })); sections.push({ title: translate('common.to'), @@ -413,18 +464,18 @@ function MoneyRequestConfirmationList({ return sections; }, [ selectedParticipants, + selectedParticipantsProp, hasMultipleParticipants, iouAmount, iouCurrencyCode, getParticipantsWithAmount, - selectedParticipantsProp, payeePersonalDetails, translate, shouldDisablePaidBySection, canModifyParticipants, ]); - const selectedOptions: Array = useMemo(() => { + const selectedOptions = useMemo(() => { if (!hasMultipleParticipants) { return []; } @@ -432,7 +483,7 @@ function MoneyRequestConfirmationList({ }, [selectedParticipants, hasMultipleParticipants, payeePersonalDetails]); useEffect(() => { - if (!isDistanceRequest) { + if (!isDistanceRequest || isMovingTransactionFromTrackExpense) { return; } @@ -444,9 +495,51 @@ function MoneyRequestConfirmationList({ IOU.setMoneyRequestPendingFields(transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(hasRoute, distance, unit, rate ?? 0, currency ?? CONST.CURRENCY.USD, translate, toLocaleDigit); - IOU.setMoneyRequestMerchant(transactionID, distanceMerchant, false); - }, [isDistanceRequestWithPendingRoute, hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, isDistanceRequest, transactionID]); + IOU.setMoneyRequestMerchant(transactionID, distanceMerchant, true); + }, [ + isDistanceRequestWithPendingRoute, + hasRoute, + distance, + unit, + rate, + currency, + translate, + toLocaleDigit, + isDistanceRequest, + transaction, + transactionID, + action, + isMovingTransactionFromTrackExpense, + ]); + + // Auto select the category if there is only one enabled category and it is required + useEffect(() => { + const enabledCategories = Object.values(policyCategories ?? {}).filter((category) => category.enabled); + if (iouCategory || !shouldShowCategories || enabledCategories.length !== 1 || !isCategoryRequired) { + return; + } + IOU.setMoneyRequestCategory(transactionID, enabledCategories[0].name); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldShowCategories, policyCategories, isCategoryRequired]); + // Auto select the tag if there is only one enabled tag and it is required + useEffect(() => { + let updatedTagsString = TransactionUtils.getTag(transaction); + policyTagLists.forEach((tagList, index) => { + const enabledTags = Object.values(tagList.tags).filter((tag) => tag.enabled); + const isTagListRequired = tagList.required === undefined ? false : tagList.required && canUseViolations; + if (!isTagListRequired || enabledTags.length !== 1 || TransactionUtils.getTag(transaction, index)) { + return; + } + updatedTagsString = IOUUtils.insertTagIntoTransactionTagsString(updatedTagsString, enabledTags[0] ? enabledTags[0].name : '', index); + }); + if (updatedTagsString !== TransactionUtils.getTag(transaction) && updatedTagsString) { + IOU.setMoneyRequestTag(transactionID, updatedTagsString); + } + }, [policyTagLists, transaction, transactionID, policyTags, canUseViolations]); + + /** + */ const selectParticipant = useCallback( (option: Participant) => { // Return early if selected option is currently logged in user. @@ -462,25 +555,38 @@ function MoneyRequestConfirmationList({ * Navigate to report details or profile of selected user */ const navigateToReportOrUserDetail = (option: ReportUtils.OptionData) => { - if (option.accountID) { - const activeRoute = Navigation.getActiveRouteWithoutParams(); + const activeRoute = Navigation.getActiveRouteWithoutParams(); + if (option.isSelfDM) { + Navigation.navigate(ROUTES.PROFILE.getRoute(currentUserPersonalDetails.accountID, activeRoute)); + return; + } + + if (option.accountID) { Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute)); } else if (option.reportID) { - Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(option.reportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(option.reportID, activeRoute)); } }; + /** + * @param {String} paymentMethod + */ const confirm = useCallback( - (paymentMethod: IouType | PaymentMethodType | undefined) => { - if (!selectedParticipants.length) { + (paymentMethod: PaymentMethodType | undefined) => { + if (selectedParticipants.length === 0) { + return; + } + if ((isMerchantRequired && isMerchantEmpty) || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null))) { + setMerchantError(true); return; } if (iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) { setFormError('iou.error.invalidCategoryLength'); return; } - if (iouType === CONST.IOU.TYPE.SEND) { + + if (iouType === CONST.IOU.TYPE.PAY) { if (!paymentMethod) { return; } @@ -490,34 +596,39 @@ function MoneyRequestConfirmationList({ Log.info(`[IOU] Sending money via: ${paymentMethod}`); onSendMoney?.(paymentMethod); } else { - // validate the amount for distance requests + // validate the amount for distance expenses const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode); if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) { setFormError('common.error.invalidAmount'); return; } - if (isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction)) { + if (isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction ?? null)) { setDidConfirmSplit(true); + setFormError('iou.error.genericSmartscanFailureMessage'); return; } + playSound(SOUNDS.DONE); setDidConfirm(true); onConfirm?.(selectedParticipants); } }, [ selectedParticipants, - onSendMoney, - onConfirm, - isEditingSplitBill, + isMerchantRequired, + isMerchantEmpty, + shouldDisplayFieldError, + transaction, iouType, + onSendMoney, + iouCurrencyCode, isDistanceRequest, iouCategory, isDistanceRequestWithPendingRoute, - iouCurrencyCode, iouAmount, - transaction, + isEditingSplitBill, + onConfirm, ], ); @@ -526,7 +637,7 @@ function MoneyRequestConfirmationList({ return; } - const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.SEND; + const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.PAY; const shouldDisableButton = selectedParticipants.length === 0; const button = shouldShowSettlementButton ? ( @@ -536,6 +647,7 @@ function MoneyRequestConfirmationList({ onPress={confirm} enablePaymentsRoute={ROUTES.IOU_SEND_ENABLE_PAYMENTS} addBankAccountRoute={bankAccountRoute} + shouldShowPersonalBankAccountOption currency={iouCurrencyCode} policyID={policyID} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} @@ -547,7 +659,6 @@ function MoneyRequestConfirmationList({ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }} - shouldShowPersonalBankAccountOption enterKeyEventListenerPriority={1} /> ) : ( @@ -555,33 +666,315 @@ function MoneyRequestConfirmationList({ success pressOnEnter isDisabled={shouldDisableButton} - // eslint-disable-next-line @typescript-eslint/naming-convention - onPress={(_event, value) => confirm(value)} + onPress={(event, value) => confirm(value as PaymentMethodType)} options={splitOrRequestOptions} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} enterKeyEventListenerPriority={1} /> ); + return ( <> - {!!formError.length && ( + {!!formError && ( )} + {button} ); - }, [isReadOnly, iouType, bankAccountRoute, iouCurrencyCode, policyID, selectedParticipants.length, confirm, splitOrRequestOptions, formError, styles.ph1, styles.mb2]); + }, [isReadOnly, iouType, selectedParticipants.length, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, formError, styles.ph1, styles.mb2]); + + // An intermediate structure that helps us classify the fields as "primary" and "supplementary". + // The primary fields are always shown to the user, while an extra action is needed to reveal the supplementary ones. + const classifiedFields = [ + { + item: ( + { + if (isDistanceRequest) { + return; + } + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + style={[styles.moneyRequestMenuItem, styles.mt2]} + titleStyle={styles.moneyRequestConfirmationAmount} + disabled={didConfirm} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction ?? null) ? translate('common.error.enterAmount') : ''} + /> + ), + shouldShow: shouldShowSmartScanFields, + isSupplementary: false, + }, + { + item: ( + { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams(), reportActionID), + ); + }} + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + disabled={didConfirm} + interactive={!isReadOnly} + numberOfLinesTitle={2} + /> + ), + shouldShow: true, + isSupplementary: false, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + // todo: handle edit for transaction while moving from track expense + interactive={!isReadOnly && !isMovingTransactionFromTrackExpense} + /> + ), + shouldShow: isDistanceRequest && !canUseP2PDistanceRequests, + isSupplementary: false, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + interactive={!isReadOnly} + /> + ), + shouldShow: isDistanceRequest && canUseP2PDistanceRequests, + isSupplementary: false, + }, + { + item: ( + 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: ( + { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={merchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={merchantError ? translate('common.error.fieldRequired') : ''} + rightLabel={isMerchantRequired ? translate('common.required') : ''} + /> + ), + shouldShow: shouldShowMerchant, + isSupplementary: !isMerchantRequired, + }, + { + item: ( + { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} + /> + ), + shouldShow: shouldShowDate, + isSupplementary: true, + }, + { + item: ( + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams(), reportActionID)) + } + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + disabled={didConfirm} + interactive={!isReadOnly} + rightLabel={isCategoryRequired ? translate('common.required') : ''} + /> + ), + shouldShow: shouldShowCategories, + isSupplementary: action === CONST.IOU.ACTION.CATEGORIZE ? false : !isCategoryRequired, + }, + ...policyTagLists.map(({name, required}, index) => { + const isTagRequired = required === undefined ? false : canUseViolations && required; + return { + item: ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(action, iouType, index, transactionID, reportID, Navigation.getActiveRouteWithoutParams(), reportActionID), + ) + } + style={[styles.moneyRequestMenuItem]} + disabled={didConfirm} + interactive={!isReadOnly} + rightLabel={isTagRequired ? translate('common.required') : ''} + /> + ), + shouldShow: shouldShowTags, + isSupplementary: !isTagRequired, + }; + }), + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + interactive={!isReadOnly} + /> + ), + shouldShow: shouldShowTax, + isSupplementary: true, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + interactive={!isReadOnly} + /> + ), + shouldShow: shouldShowTax, + isSupplementary: true, + }, + { + item: ( + + {translate('common.billable')} + onToggleBillable?.(isOn)} + /> + + ), + shouldShow: shouldShowBillable, + isSupplementary: true, + }, + ]; + + const primaryFields = classifiedFields.filter((classifiedField) => classifiedField.shouldShow && !classifiedField.isSupplementary).map((primaryField) => primaryField.item); + + const supplementaryFields = classifiedFields + .filter((classifiedField) => classifiedField.shouldShow && classifiedField.isSupplementary) + .map((supplementaryField) => supplementaryField.item); const { image: receiptImage, thumbnail: receiptThumbnail, isThumbnail, fileExtension, - } = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : ({} as ReceiptUtils.ThumbnailAndImageURI); + isLocalFile, + } = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction ?? null, receiptPath, receiptFilename) : ({} as ReceiptUtils.ThumbnailAndImageURI); + + const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); + const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); + + const receiptThumbnailContent = useMemo( + () => + isLocalFile && Str.isPDF(receiptFilename) ? ( + setIsAttachmentInvalid(true)} + /> + ) : ( + + ), + [isLocalFile, receiptFilename, resolvedThumbnail, styles.moneyRequestImage, isAttachmentInvalid, isThumbnail, resolvedReceiptImage, receiptThumbnail, fileExtension], + ); + return ( // @ts-expect-error This component is deprecated and will not be migrated to TypeScript (context: https://expensify.slack.com/archives/C01GTK53T8Q/p1709232289899589?thread_ts=1709156803.359359&cid=C01GTK53T8Q) {isDistanceRequest && ( - + )} - {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} - {receiptImage || receiptThumbnail ? ( - - ) : ( - // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") - PolicyUtils.isPaidGroupPolicy(policy) && - !isDistanceRequest && - iouType === CONST.IOU.TYPE.REQUEST && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( - CONST.IOU.ACTION.CREATE, - iouType, - transaction?.transactionID ?? '', - reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ) - } - /> - ) - )} - {shouldShowSmartScanFields && ( - { - if (isDistanceRequest) { - return; - } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), - ); - }} - style={[styles.moneyRequestMenuItem, styles.mt2]} - titleStyle={styles.moneyRequestConfirmationAmount} - disabled={didConfirm} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} - /> - )} - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction?.transactionID ?? '', - reportID, - Navigation.getActiveRouteWithoutParams(), - reportActionID, - ), - ); - }} - style={styles.moneyRequestMenuItem} - titleStyle={styles.flex1} - disabled={didConfirm} - interactive={!isReadOnly} - numberOfLinesTitle={2} - /> + {!isDistanceRequest && + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (receiptImage || receiptThumbnail + ? receiptThumbnailContent + : // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") + PolicyUtils.isPaidGroupPolicy(policy) && + !isDistanceRequest && + iouType === CONST.IOU.TYPE.SUBMIT && ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ) + } + /> + ))} + {primaryFields} {!shouldShowAllFields && ( )} - {shouldShowAllFields && ( - <> - {shouldShowDate && ( - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DATE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction?.transactionID ?? '', - reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ); - }} - disabled={didConfirm} - interactive={!isReadOnly} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} - /> - )} - {isDistanceRequest && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction?.transactionID ?? '', - reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ) - } - disabled={didConfirm} - interactive={!isReadOnly} - /> - )} - {shouldShowMerchant && ( - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction?.transactionID ?? '', - reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ); - }} - disabled={didConfirm} - interactive={!isReadOnly} - brickRoadIndicator={shouldDisplayMerchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={shouldDisplayMerchantError ? translate('common.error.enterMerchant') : ''} - /> - )} - {shouldShowCategories && ( - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction?.transactionID ?? '', - reportID, - Navigation.getActiveRouteWithoutParams(), - reportActionID, - ), - ); - }} - style={styles.moneyRequestMenuItem} - titleStyle={styles.flex1} - disabled={didConfirm} - interactive={!isReadOnly} - rightLabel={canUseViolations && !!policy?.requiresCategory ? translate('common.required') : ''} - /> - )} - {shouldShowTags && - policyTagLists.map(({name}, index) => ( - { - if (!isEditingSplitBill) { - return; - } - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute( - CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.SPLIT, - index, - transaction?.transactionID ?? '', - reportID, - Navigation.getActiveRouteWithoutParams(), - reportActionID, - ), - ); - }} - style={styles.moneyRequestMenuItem} - disabled={didConfirm} - interactive={!isReadOnly} - rightLabel={canUseViolations && !!policy?.requiresTag ? translate('common.required') : ''} - /> - ))} - - {shouldShowTax && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute( - CONST.IOU.ACTION.CREATE, - iouType, - transaction?.transactionID ?? '', - reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ) - } - disabled={didConfirm} - interactive={!isReadOnly} - /> - )} - - {shouldShowTax && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute( - CONST.IOU.ACTION.CREATE, - iouType, - transaction?.transactionID ?? '', - reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ) - } - disabled={didConfirm} - interactive={!isReadOnly} - /> - )} - - {shouldShowBillable && ( - - {translate('common.billable')} - onToggleBillable?.(isOn)} - /> - - )} - - )} + {shouldShowAllFields && supplementaryFields} + ); } @@ -893,14 +1049,18 @@ export default withOnyx `${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}`, }, - iou: { - key: ONYXKEYS.IOU, + lastSelectedDistanceRates: { + key: ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, }, })(MoneyRequestConfirmationList); diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index f7825ef2f622..da087c57fcba 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -96,8 +96,12 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, if (isOnHold) { IOU.unholdRequest(iouTransactionID, report?.reportID); } else { + if (!policy?.type) { + return; + } + const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); - Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? '', iouTransactionID, report?.reportID, activeRoute)); + Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy.type, iouTransactionID, report?.reportID, activeRoute)); } }; diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.tsx b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.tsx deleted file mode 100755 index d6ac31749a27..000000000000 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.tsx +++ /dev/null @@ -1,1015 +0,0 @@ -import {useIsFocused} from '@react-navigation/native'; -import {format} from 'date-fns'; -import Str from 'expensify-common/lib/str'; -import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react'; -import {View} from 'react-native'; -import type {StyleProp, ViewStyle} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; -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 * as IOUUtils from '@libs/IOUUtils'; -import Log from '@libs/Log'; -import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import {isTaxTrackingEnabled} from '@libs/PolicyUtils'; -import * as ReceiptUtils from '@libs/ReceiptUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import playSound, {SOUNDS} from '@libs/Sound'; -import * as TransactionUtils from '@libs/TransactionUtils'; -import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; -import * as IOU from '@userActions/IOU'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {Route} from '@src/ROUTES'; -import type * as OnyxTypes from '@src/types/onyx'; -import type {Participant} from '@src/types/onyx/IOU'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -import Button from './Button'; -import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; -import type {DropdownOption} from './ButtonWithDropdownMenu/types'; -import ConfirmedRoute from './ConfirmedRoute'; -import ConfirmModal from './ConfirmModal'; -import FormHelpMessage from './FormHelpMessage'; -import * as Expensicons from './Icon/Expensicons'; -import MenuItemWithTopDescription from './MenuItemWithTopDescription'; -import OptionsSelector from './OptionsSelector'; -import PDFThumbnail from './PDFThumbnail'; -import ReceiptEmptyState from './ReceiptEmptyState'; -import ReceiptImage from './ReceiptImage'; -import SettlementButton from './SettlementButton'; -import Switch from './Switch'; -import Text from './Text'; - -type MoneyRequestConfirmationListOnyxProps = { - /** Collection of categories attached to a policy */ - policyCategories: OnyxEntry; - - /** Collection of tags attached to a policy */ - policyTags: OnyxEntry; - - /** The policy of the report */ - policy: OnyxEntry; - - /** The session of the logged in user */ - session: OnyxEntry; - - /** Unit and rate used for if the expense is a distance expense */ - mileageRate: OnyxEntry; -}; - -type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & { - /** Callback to inform parent modal of success */ - onConfirm?: (selectedParticipants: Participant[]) => void; - - /** Callback to parent modal to pay someone */ - onSendMoney?: (paymentMethod: PaymentMethodType | undefined) => void; - - /** Callback to inform a participant is selected */ - onSelectParticipant?: (option: Participant) => void; - - /** Should we request a single or multiple participant selection from user */ - hasMultipleParticipants: boolean; - - /** IOU amount */ - iouAmount: number; - - /** IOU comment */ - iouComment?: string; - - /** IOU currency */ - iouCurrencyCode?: string; - - /** IOU type */ - iouType?: ValueOf; - - /** IOU date */ - iouCreated?: string; - - /** IOU merchant */ - iouMerchant?: string; - - /** IOU Category */ - iouCategory?: string; - - /** IOU isBillable */ - iouIsBillable?: boolean; - - /** Callback to toggle the billable state */ - onToggleBillable?: (isOn: boolean) => void; - - /** Selected participants from MoneyRequestModal with login / accountID */ - selectedParticipants: Participant[]; - - /** Payee of the expense with login */ - payeePersonalDetails?: OnyxTypes.PersonalDetails; - - /** Can the participants be modified or not */ - canModifyParticipants?: boolean; - - /** Should the list be read only, and not editable? */ - isReadOnly?: boolean; - - /** Depending on expense report or personal IOU report, respective bank account route */ - bankAccountRoute?: Route; - - /** The policyID of the request */ - policyID?: string; - - /** The reportID of the request */ - reportID?: string; - - /** File path of the receipt */ - receiptPath?: string; - - /** File name of the receipt */ - receiptFilename?: string; - - /** List styles for OptionsSelector */ - listStyles?: StyleProp; - - /** Transaction that represents the expense */ - transaction?: OnyxEntry; - - /** Whether the expense is a distance expense */ - isDistanceRequest?: boolean; - - /** Whether the expense is a scan expense */ - isScanRequest?: boolean; - - /** Whether we're editing a split expense */ - isEditingSplitBill?: boolean; - - /** Whether we should show the amount, date, and merchant fields. */ - shouldShowSmartScanFields?: boolean; - - /** A flag for verifying that the current report is a sub-report of a workspace chat */ - isPolicyExpenseChat?: boolean; - - /** Whether smart scan failed */ - hasSmartScanFailed?: boolean; - - reportActionID?: string; - - action?: ValueOf; -}; - -const getTaxAmount = (transaction: OnyxEntry, defaultTaxValue: string) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const percentage = (transaction?.taxRate ? transaction?.taxRate?.data?.value : defaultTaxValue) || ''; - return TransactionUtils.calculateTaxAmount(percentage, transaction?.amount ?? 0); -}; - -function MoneyTemporaryForRefactorRequestConfirmationList({ - transaction = null, - onSendMoney, - onConfirm, - onSelectParticipant, - iouType = CONST.IOU.TYPE.REQUEST, - isScanRequest = false, - iouAmount, - policyCategories, - mileageRate, - isDistanceRequest = false, - policy, - isPolicyExpenseChat = false, - iouCategory = '', - shouldShowSmartScanFields = true, - isEditingSplitBill, - policyTags, - iouCurrencyCode, - iouMerchant, - hasMultipleParticipants, - selectedParticipants: pickedParticipants, - payeePersonalDetails, - canModifyParticipants = false, - session, - isReadOnly = false, - bankAccountRoute = '', - policyID = '', - reportID = '', - receiptPath = '', - iouComment, - receiptFilename = '', - listStyles, - iouCreated, - iouIsBillable = false, - onToggleBillable, - hasSmartScanFailed, - reportActionID, - action = CONST.IOU.ACTION.CREATE, -}: MoneyRequestConfirmationListProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const {translate, toLocaleDigit} = useLocalize(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {canUseViolations} = usePermissions(); - - 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: 'USD', - }; - const distance = transaction?.routes?.route0.distance ?? 0; - const shouldCalculateDistanceAmount = isDistanceRequest && iouAmount === 0; - const taxRates = policy?.taxRates; - - // A flag for showing the categories field - const shouldShowCategories = isPolicyExpenseChat && (!!iouCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {}))); - - // A flag and a toggler for showing the rest of the form fields - const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false); - - // Do not hide fields in case of paying someone - const shouldShowAllFields = isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || isEditingSplitBill; - - const shouldShowDate = (shouldShowSmartScanFields || isDistanceRequest) && !isTypeSend; - const shouldShowMerchant = shouldShowSmartScanFields && !isDistanceRequest && !isTypeSend; - - const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); - - // A flag for showing the tags field - const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists), [isPolicyExpenseChat, policyTagLists]); - - // A flag for showing tax rate - const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy); - - // A flag for showing the billable field - const shouldShowBillable = policy?.disabledFields?.defaultBillable === false; - const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); - const hasRoute = TransactionUtils.hasRoute(transaction); - const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense; - const formattedAmount = isDistanceRequestWithPendingRoute - ? '' - : CurrencyUtils.convertToDisplayString( - shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate ?? 0) : iouAmount, - isDistanceRequest ? currency : iouCurrencyCode, - ); - const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode); - const taxRateTitle = taxRates && transaction ? TransactionUtils.getDefaultTaxName(taxRates, transaction) : ''; - - const previousTransactionAmount = usePrevious(transaction?.amount); - - const isFocused = useIsFocused(); - const [formError, setFormError] = useState(''); - - const [didConfirm, setDidConfirm] = useState(false); - const [didConfirmSplit, setDidConfirmSplit] = useState(false); - - const [merchantError, setMerchantError] = useState(false); - - const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); - - const navigateBack = () => { - Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction?.transactionID ?? '', reportID)); - }; - - const shouldDisplayFieldError: boolean = useMemo(() => { - if (!isEditingSplitBill) { - return false; - } - - return (!!hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction)); - }, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]); - - const isMerchantEmpty = !iouMerchant || iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; - const isMerchantRequired = isPolicyExpenseChat && !isScanRequest && shouldShowMerchant; - - const isCategoryRequired = canUseViolations && !!policy?.requiresCategory; - - useEffect(() => { - if ((!isMerchantRequired && isMerchantEmpty) || !merchantError) { - return; - } - if (!isMerchantEmpty && merchantError) { - setMerchantError(false); - if (formError === 'iou.error.invalidMerchant') { - setFormError(''); - } - } - }, [formError, isMerchantEmpty, merchantError, isMerchantRequired]); - - useEffect(() => { - if (shouldDisplayFieldError && hasSmartScanFailed) { - setFormError('iou.receiptScanningFailed'); - return; - } - if (shouldDisplayFieldError && didConfirmSplit) { - setFormError('iou.error.genericSmartscanFailureMessage'); - return; - } - if (merchantError) { - setFormError('iou.error.invalidMerchant'); - return; - } - // reset the form error whenever the screen gains or loses focus - setFormError(''); - }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isMerchantRequired, merchantError]); - - useEffect(() => { - if (!shouldCalculateDistanceAmount) { - return; - } - - const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate ?? 0); - IOU.setMoneyRequestAmount_temporaryForRefactor(transaction?.transactionID ?? '', amount, currency ?? ''); - }, [shouldCalculateDistanceAmount, distance, rate, unit, transaction, currency]); - - // Calculate and set tax amount in transaction draft - useEffect(() => { - const taxAmount = getTaxAmount(transaction, taxRates?.defaultValue ?? '').toString(); - const amountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(taxAmount)); - - if (transaction?.taxAmount && previousTransactionAmount === transaction?.amount) { - return IOU.setMoneyRequestTaxAmount(transaction?.transactionID, transaction?.taxAmount, true); - } - - IOU.setMoneyRequestTaxAmount(transaction?.transactionID ?? '', amountInSmallestCurrencyUnits, true); - }, [taxRates?.defaultValue, transaction, previousTransactionAmount]); - - /** - * Returns the participants with amount - */ - const getParticipantsWithAmount = useCallback( - (participantsList: Participant[]) => { - const amount = IOUUtils.calculateAmount(participantsList.length, iouAmount, iouCurrencyCode ?? ''); - return OptionsListUtils.getIOUConfirmationOptionsFromParticipants(participantsList, amount > 0 ? CurrencyUtils.convertToDisplayString(amount, iouCurrencyCode) : ''); - }, - [iouAmount, iouCurrencyCode], - ); - - // If completing a split expense fails, set didConfirm to false to allow the user to edit the fields again - if (isEditingSplitBill && didConfirm) { - setDidConfirm(false); - } - - const splitOrRequestOptions: Array> = useMemo(() => { - let text; - if (isTypeTrackExpense) { - text = translate('iou.trackExpense'); - } else if (isTypeSplit && iouAmount === 0) { - text = translate('iou.splitExpense'); - } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { - text = translate('iou.submitExpense'); - if (iouAmount !== 0) { - text = translate('iou.submitAmount', {amount: formattedAmount}); - } - } else { - const translationKey = isTypeSplit ? 'iou.splitAmount' : 'iou.submitAmount'; - text = translate(translationKey, {amount: formattedAmount}); - } - return [ - { - text: text[0].toUpperCase() + text.slice(1), - value: iouType, - }, - ]; - }, [isTypeTrackExpense, isTypeSplit, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount]); - - const selectedParticipants = useMemo(() => pickedParticipants.filter((participant) => participant.selected), [pickedParticipants]); - const personalDetailsOfPayee = useMemo(() => payeePersonalDetails ?? currentUserPersonalDetails, [payeePersonalDetails, currentUserPersonalDetails]); - const userCanModifyParticipants = useRef(!isReadOnly && canModifyParticipants && hasMultipleParticipants); - useEffect(() => { - userCanModifyParticipants.current = !isReadOnly && canModifyParticipants && hasMultipleParticipants; - }, [isReadOnly, canModifyParticipants, hasMultipleParticipants]); - const shouldDisablePaidBySection = userCanModifyParticipants.current; - - const optionSelectorSections = useMemo(() => { - const sections = []; - const unselectedParticipants = pickedParticipants.filter((participant) => !participant.selected); - if (hasMultipleParticipants) { - const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipants); - let formattedParticipantsList = [...new Set([...formattedSelectedParticipants, ...unselectedParticipants])]; - - if (!canModifyParticipants) { - formattedParticipantsList = formattedParticipantsList.map((participant) => ({ - ...participant, - isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), - })); - } - - const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, iouAmount, iouCurrencyCode ?? '', true); - const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail( - personalDetailsOfPayee, - iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode) : '', - ); - - sections.push( - { - title: translate('moneyRequestConfirmationList.paidBy'), - data: [formattedPayeeOption], - shouldShow: true, - isDisabled: shouldDisablePaidBySection, - }, - { - title: translate('moneyRequestConfirmationList.splitWith'), - data: formattedParticipantsList, - shouldShow: true, - }, - ); - } else { - const formattedSelectedParticipants = selectedParticipants.map((participant) => ({ - ...participant, - isDisabled: !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), - })); - sections.push({ - title: translate('common.to'), - data: formattedSelectedParticipants, - shouldShow: true, - }); - } - return sections; - }, [ - selectedParticipants, - pickedParticipants, - hasMultipleParticipants, - iouAmount, - iouCurrencyCode, - getParticipantsWithAmount, - personalDetailsOfPayee, - translate, - shouldDisablePaidBySection, - canModifyParticipants, - ]); - - const selectedOptions = useMemo(() => { - if (!hasMultipleParticipants) { - return []; - } - return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetailsOfPayee)]; - }, [selectedParticipants, hasMultipleParticipants, personalDetailsOfPayee]); - - useEffect(() => { - if (!isDistanceRequest || isMovingTransactionFromTrackExpense) { - return; - } - - /* - Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as: - When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page. - In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending. - */ - IOU.setMoneyRequestPendingFields(transaction?.transactionID ?? '', {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); - - const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(hasRoute, distance, unit, rate ?? 0, currency ?? 'USD', translate, toLocaleDigit); - IOU.setMoneyRequestMerchant(transaction?.transactionID ?? '', distanceMerchant, true); - }, [isDistanceRequestWithPendingRoute, hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, isDistanceRequest, transaction, action, isMovingTransactionFromTrackExpense]); - - // Auto select the category if there is only one enabled category and it is required - useEffect(() => { - const enabledCategories = Object.values(policyCategories ?? {}).filter((category) => category.enabled); - if (iouCategory || !shouldShowCategories || enabledCategories.length !== 1 || !isCategoryRequired) { - return; - } - IOU.setMoneyRequestCategory(transaction?.transactionID ?? '', enabledCategories[0].name); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldShowCategories, policyCategories, isCategoryRequired]); - - // Auto select the tag if there is only one enabled tag and it is required - useEffect(() => { - let updatedTagsString = TransactionUtils.getTag(transaction); - policyTagLists.forEach((tagList, index) => { - const enabledTags = Object.values(tagList.tags).filter((tag) => tag.enabled); - const isTagListRequired = tagList.required === undefined ? false : tagList.required && canUseViolations; - if (!isTagListRequired || enabledTags.length !== 1 || TransactionUtils.getTag(transaction, index)) { - return; - } - updatedTagsString = IOUUtils.insertTagIntoTransactionTagsString(updatedTagsString, enabledTags[0] ? enabledTags[0].name : '', index); - }); - if (updatedTagsString !== TransactionUtils.getTag(transaction) && updatedTagsString) { - IOU.setMoneyRequestTag(transaction?.transactionID ?? '', updatedTagsString); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [policyTagLists, policyTags, canUseViolations]); - - /** - */ - const selectParticipant = useCallback( - (option: Participant) => { - // Return early if selected option is currently logged in user. - if (option.accountID === session?.accountID) { - return; - } - onSelectParticipant?.(option); - }, - [session?.accountID, onSelectParticipant], - ); - - /** - * Navigate to report details or profile of selected user - */ - const navigateToReportOrUserDetail = (option: ReportUtils.OptionData) => { - const activeRoute = Navigation.getActiveRouteWithoutParams(); - - if (option.isSelfDM) { - Navigation.navigate(ROUTES.PROFILE.getRoute(currentUserPersonalDetails.accountID, activeRoute)); - return; - } - - if (option.accountID) { - Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute)); - } else if (option.reportID) { - Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(option.reportID, activeRoute)); - } - }; - - /** - * @param {String} paymentMethod - */ - const confirm = useCallback( - (paymentMethod: PaymentMethodType | undefined) => { - if (selectedParticipants.length === 0) { - return; - } - if ((isMerchantRequired && isMerchantEmpty) || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null))) { - setMerchantError(true); - return; - } - - if (iouType === CONST.IOU.TYPE.SEND) { - if (!paymentMethod) { - return; - } - - setDidConfirm(true); - - Log.info(`[IOU] Sending money via: ${paymentMethod}`); - onSendMoney?.(paymentMethod); - } else { - // validate the amount for distance expenses - const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode); - if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) { - setFormError('common.error.invalidAmount'); - return; - } - - if (isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction ?? null)) { - setDidConfirmSplit(true); - setFormError('iou.error.genericSmartscanFailureMessage'); - return; - } - - playSound(SOUNDS.DONE); - setDidConfirm(true); - onConfirm?.(selectedParticipants); - } - }, - [ - selectedParticipants, - isMerchantRequired, - isMerchantEmpty, - shouldDisplayFieldError, - transaction, - iouType, - onSendMoney, - iouCurrencyCode, - isDistanceRequest, - isDistanceRequestWithPendingRoute, - iouAmount, - isEditingSplitBill, - onConfirm, - ], - ); - - const footerContent = useMemo(() => { - if (isReadOnly) { - return; - } - - const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.SEND; - const shouldDisableButton = selectedParticipants.length === 0; - - const button = shouldShowSettlementButton ? ( - - ) : ( - confirm(value as PaymentMethodType)} - options={splitOrRequestOptions} - buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} - enterKeyEventListenerPriority={1} - /> - ); - - return ( - <> - {!!formError && ( - - )} - - {button} - - ); - }, [isReadOnly, iouType, selectedParticipants.length, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, formError, styles.ph1, styles.mb2]); - - // An intermediate structure that helps us classify the fields as "primary" and "supplementary". - // The primary fields are always shown to the user, while an extra action is needed to reveal the supplementary ones. - const classifiedFields = [ - { - item: ( - { - if (isDistanceRequest) { - return; - } - if (isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID ?? '', CONST.EDIT_REQUEST_FIELD.AMOUNT)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(action, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams())); - }} - style={[styles.moneyRequestMenuItem, styles.mt2]} - titleStyle={styles.moneyRequestConfirmationAmount} - disabled={didConfirm} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction ?? null) ? translate('common.error.enterAmount') : ''} - /> - ), - shouldShow: shouldShowSmartScanFields, - isSupplementary: false, - }, - { - item: ( - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(action, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), - ); - }} - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - disabled={didConfirm} - interactive={!isReadOnly} - numberOfLinesTitle={2} - /> - ), - shouldShow: true, - isSupplementary: false, - }, - { - item: ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transaction?.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, - }, - { - item: ( - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(action, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), - ); - }} - disabled={didConfirm} - interactive={!isReadOnly} - brickRoadIndicator={merchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={merchantError ? translate('common.error.fieldRequired') : ''} - rightLabel={isMerchantRequired ? translate('common.required') : ''} - /> - ), - shouldShow: shouldShowMerchant, - isSupplementary: !isMerchantRequired, - }, - { - item: ( - { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(action, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams())); - }} - disabled={didConfirm} - interactive={!isReadOnly} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} - /> - ), - shouldShow: shouldShowDate, - isSupplementary: true, - }, - { - item: ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), - ) - } - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - disabled={didConfirm} - interactive={!isReadOnly} - rightLabel={isCategoryRequired ? translate('common.required') : ''} - /> - ), - shouldShow: shouldShowCategories, - isSupplementary: action === CONST.IOU.ACTION.CATEGORIZE ? false : !isCategoryRequired, - }, - ...policyTagLists.map(({name, required}, index) => { - const isTagRequired = required === undefined ? false : canUseViolations && required; - return { - item: ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(action, iouType, index, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), - ) - } - style={[styles.moneyRequestMenuItem]} - disabled={didConfirm} - interactive={!isReadOnly} - rightLabel={isTagRequired ? translate('common.required') : ''} - /> - ), - shouldShow: shouldShowTags, - isSupplementary: !isTagRequired, - }; - }), - { - item: ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(action, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), - ) - } - disabled={didConfirm} - interactive={!isReadOnly} - /> - ), - shouldShow: shouldShowTax, - isSupplementary: true, - }, - { - item: ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(action, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), - ) - } - disabled={didConfirm} - interactive={!isReadOnly} - /> - ), - shouldShow: shouldShowTax, - isSupplementary: true, - }, - { - item: ( - - {translate('common.billable')} - onToggleBillable?.(isOn)} - /> - - ), - shouldShow: shouldShowBillable, - isSupplementary: true, - }, - ]; - - const primaryFields = classifiedFields.filter((classifiedField) => classifiedField.shouldShow && !classifiedField.isSupplementary).map((primaryField) => primaryField.item); - - const supplementaryFields = classifiedFields - .filter((classifiedField) => classifiedField.shouldShow && classifiedField.isSupplementary) - .map((supplementaryField) => supplementaryField.item); - - const { - image: receiptImage, - thumbnail: receiptThumbnail, - isThumbnail, - fileExtension, - isLocalFile, - } = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction ?? null, receiptPath, receiptFilename) : ({} as ReceiptUtils.ThumbnailAndImageURI); - - const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); - const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); - - const receiptThumbnailContent = useMemo( - () => - isLocalFile && Str.isPDF(receiptFilename) ? ( - setIsAttachmentInvalid(true)} - /> - ) : ( - - ), - [isLocalFile, receiptFilename, resolvedThumbnail, styles.moneyRequestImage, isAttachmentInvalid, isThumbnail, resolvedReceiptImage, receiptThumbnail, fileExtension], - ); - - return ( - // @ts-expect-error This component is deprecated and will not be migrated to TypeScript (context: https://expensify.slack.com/archives/C01GTK53T8Q/p1709232289899589?thread_ts=1709156803.359359&cid=C01GTK53T8Q) - - {isDistanceRequest && ( - - - - )} - {(!isMovingTransactionFromTrackExpense || !hasRoute) && - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (receiptImage || receiptThumbnail - ? receiptThumbnailContent - : // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") - PolicyUtils.isPaidGroupPolicy(policy) && - !isDistanceRequest && - iouType === CONST.IOU.TYPE.REQUEST && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( - CONST.IOU.ACTION.CREATE, - iouType, - transaction?.transactionID ?? '', - reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ) - } - /> - ))} - {primaryFields} - {!shouldShowAllFields && ( - - -