diff --git a/.eslintrc.js b/.eslintrc.js index 5451cfff6534..0661183101ab 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -194,6 +194,7 @@ module.exports = { { selector: ['parameter', 'method'], format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', }, ], '@typescript-eslint/ban-types': [ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d34e4ebbf895..36b921570e7f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -103,9 +103,9 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c - [ ] If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like `Avatar` is modified, I verified that `Avatar` is working as expected in all cases) - [ ] If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected. - [ ] If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account. -- [ ] If the PR modifies the form input styles: +- [ ] If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles: - [ ] I verified that all the inputs inside a form are aligned with each other. - - [ ] I added `Design` label so the design team can review the changes. + - [ ] I added `Design` label and/or tagged `@Expensify/design` so the design team can review the changes. - [ ] If a new page is added, I verified it's using the `ScrollView` component to make it scrollable when more elements are added to the page. - [ ] If the `main` branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the `Test` steps. diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 8a47ea4bb220..fd814ad69a7c 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -183,7 +183,7 @@ jobs: run: npm run e2e-test-runner-build - name: Copy e2e code into zip folder - run: cp tests/e2e/dist/index.js zip/testRunner.js + run: cp tests/e2e/dist/index.js zip/testRunner.ts - name: Zip everything in the zip directory up run: zip -qr App.zip ./zip diff --git a/android/app/build.gradle b/android/app/build.gradle index ee8aa986ddf2..b792f7830ea4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001045005 - versionName "1.4.50-5" + versionCode 1001045103 + versionName "1.4.51-3" } flavorDimensions "default" diff --git a/assets/fonts/web/ExpensifyNewKansas-Medium.woff b/assets/fonts/web/ExpensifyNewKansas-Medium.woff index bd842c5ecb1d..9e4258763f58 100644 Binary files a/assets/fonts/web/ExpensifyNewKansas-Medium.woff and b/assets/fonts/web/ExpensifyNewKansas-Medium.woff differ diff --git a/assets/fonts/web/ExpensifyNewKansas-Medium.woff2 b/assets/fonts/web/ExpensifyNewKansas-Medium.woff2 index dba1df7e971e..1f65d0df0fcb 100644 Binary files a/assets/fonts/web/ExpensifyNewKansas-Medium.woff2 and b/assets/fonts/web/ExpensifyNewKansas-Medium.woff2 differ diff --git a/assets/fonts/web/ExpensifyNewKansas-MediumItalic.woff b/assets/fonts/web/ExpensifyNewKansas-MediumItalic.woff index d3e7d9e82e15..5bab939ee71d 100644 Binary files a/assets/fonts/web/ExpensifyNewKansas-MediumItalic.woff and b/assets/fonts/web/ExpensifyNewKansas-MediumItalic.woff differ diff --git a/assets/fonts/web/ExpensifyNewKansas-MediumItalic.woff2 b/assets/fonts/web/ExpensifyNewKansas-MediumItalic.woff2 index 94a0e04fa3b2..589edf3bc922 100644 Binary files a/assets/fonts/web/ExpensifyNewKansas-MediumItalic.woff2 and b/assets/fonts/web/ExpensifyNewKansas-MediumItalic.woff2 differ diff --git a/contributingGuides/REVIEWER_CHECKLIST.md b/contributingGuides/REVIEWER_CHECKLIST.md index ab4b215516b9..4ff1f01b1475 100644 --- a/contributingGuides/REVIEWER_CHECKLIST.md +++ b/contributingGuides/REVIEWER_CHECKLIST.md @@ -51,9 +51,9 @@ - [ ] If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like `Avatar` is modified, I verified that `Avatar` is working as expected in all cases) - [ ] If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected. - [ ] If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account. -- [ ] If the PR modifies the form input styles: +- [ ] If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles: - [ ] I verified that all the inputs inside a form are aligned with each other. - - [ ] I added `Design` label so the design team can review the changes. + - [ ] I added `Design` label and/or tagged `@Expensify/design` so the design team can review the changes. - [ ] If a new page is added, I verified it's using the `ScrollView` component to make it scrollable when more elements are added to the page. - [ ] If the `main` branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the `Test` steps. - [ ] I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR. diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index ea18acef7c23..ec0f76801bc7 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -508,6 +508,7 @@ button { .info { padding: 12px; + margin-bottom: 20px; border-radius: 8px; background-color: $color-highlightBG; color: $color-text; diff --git a/docs/articles/expensify-classic/expensify-card/Request-the-Card.md b/docs/articles/expensify-classic/expensify-card/Request-the-Card.md index 9940535e1fad..724745f458ef 100644 --- a/docs/articles/expensify-classic/expensify-card/Request-the-Card.md +++ b/docs/articles/expensify-classic/expensify-card/Request-the-Card.md @@ -38,6 +38,10 @@ If you need to cancel your Expensify Card and cannot access the website or mobil It's not possible to order a replacement card over the phone, so, if applicable, you would need to handle this step from your Expensify account. +# Card Expiration Date + +If you notice that your card expiration date is soon, it's time for a new Expensify card. Expensify will automatically input a notification in your account's Home (Inbox) tab. This notice will ask you to input your address, but this is more if you have changed your address since your card was issued to you. You can ignore it and do nothing; the new Expensify card will ship to your address on file. The new Expensify card will have a new, unique card number and will not be associated with the old one. + {% include faq-begin.md %} ## What if I haven’t received my card after multiple weeks? diff --git a/docs/expensify-classic/hubs/expense-and-report-features/index.html b/docs/expensify-classic/hubs/copilots-and-delegates/index.html similarity index 58% rename from docs/expensify-classic/hubs/expense-and-report-features/index.html rename to docs/expensify-classic/hubs/copilots-and-delegates/index.html index 44afa4b18b51..a2147a6a61ae 100644 --- a/docs/expensify-classic/hubs/expense-and-report-features/index.html +++ b/docs/expensify-classic/hubs/copilots-and-delegates/index.html @@ -1,6 +1,6 @@ --- layout: default -title: Expense & Report Settings +title: Copilots & Delegates --- {% include hub.html %} \ No newline at end of file diff --git a/docs/expensify-classic/hubs/get-paid-back/index.html b/docs/expensify-classic/hubs/domains/index.html similarity index 69% rename from docs/expensify-classic/hubs/get-paid-back/index.html rename to docs/expensify-classic/hubs/domains/index.html index 1f84c1510b92..fd3b4727cce7 100644 --- a/docs/expensify-classic/hubs/get-paid-back/index.html +++ b/docs/expensify-classic/hubs/domains/index.html @@ -1,6 +1,6 @@ --- layout: default -title: Get Paid Back +title: Domains --- {% include hub.html %} \ No newline at end of file diff --git a/docs/expensify-classic/hubs/get-paid-back/expenses.html b/docs/expensify-classic/hubs/expenses/expenses.html similarity index 100% rename from docs/expensify-classic/hubs/get-paid-back/expenses.html rename to docs/expensify-classic/hubs/expenses/expenses.html diff --git a/docs/expensify-classic/hubs/workspace-and-domain-settings/index.html b/docs/expensify-classic/hubs/expenses/index.html similarity index 58% rename from docs/expensify-classic/hubs/workspace-and-domain-settings/index.html rename to docs/expensify-classic/hubs/expenses/index.html index ffd514fcb6fa..66aa2c74ac57 100644 --- a/docs/expensify-classic/hubs/workspace-and-domain-settings/index.html +++ b/docs/expensify-classic/hubs/expenses/index.html @@ -1,6 +1,6 @@ --- layout: default -title: Policy And Domain Settings +title: Expenses --- {% include hub.html %} \ No newline at end of file diff --git a/docs/expensify-classic/hubs/get-paid-back/reports.html b/docs/expensify-classic/hubs/expenses/reports.html similarity index 100% rename from docs/expensify-classic/hubs/get-paid-back/reports.html rename to docs/expensify-classic/hubs/expenses/reports.html diff --git a/docs/expensify-classic/hubs/manage-employees-and-report-approvals/index.html b/docs/expensify-classic/hubs/reports/index.html similarity index 51% rename from docs/expensify-classic/hubs/manage-employees-and-report-approvals/index.html rename to docs/expensify-classic/hubs/reports/index.html index 788e445ebc91..627274fc2391 100644 --- a/docs/expensify-classic/hubs/manage-employees-and-report-approvals/index.html +++ b/docs/expensify-classic/hubs/reports/index.html @@ -1,6 +1,6 @@ --- layout: default -title: Manage Employees And Report Approvals +title: Reports --- {% include hub.html %} \ No newline at end of file diff --git a/docs/expensify-classic/hubs/workspaces/index.html b/docs/expensify-classic/hubs/workspaces/index.html new file mode 100644 index 000000000000..436c9fcfecb1 --- /dev/null +++ b/docs/expensify-classic/hubs/workspaces/index.html @@ -0,0 +1,6 @@ +--- +layout: default +title: Workspaces +--- + +{% include hub.html %} \ No newline at end of file diff --git a/docs/expensify-classic/hubs/workspace-and-domain-settings/reports.html b/docs/expensify-classic/hubs/workspaces/reports.html similarity index 100% rename from docs/expensify-classic/hubs/workspace-and-domain-settings/reports.html rename to docs/expensify-classic/hubs/workspaces/reports.html diff --git a/docs/redirects.csv b/docs/redirects.csv index 3c89e920e3f7..df4e2a45dce3 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -65,3 +65,4 @@ https://help.expensify.com/articles/expensify-classic/expensify-billing/Pay-Per- https://help.expensify.com/articles/expensify-classic/expensify-billing/Individual-Subscription,https://use.expensify.com/ https://help.expensify.com/articles/expensify-classic/settings/Merge-Accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts https://help.expensify.com/articles/expensify-classic/settings/Preferences,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://help.expensify.com/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager,https://use.expensify.com/support diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index 9ca8eba7c94f..c4412cf650ee 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 b5751f0986fa..999442b550da 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/Info.plist b/ios/NewExpensify/Info.plist index 127f98edc070..ab5d359a5460 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.50 + 1.4.51 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.50.5 + 1.4.51.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 8a2fef8c99c6..ca9200c78376 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.50 + 1.4.51 CFBundleSignature ???? CFBundleVersion - 1.4.50.5 + 1.4.51.3 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 34ef35759e15..f20b520b1480 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.50 + 1.4.51 CFBundleVersion - 1.4.50.5 + 1.4.51.3 NSExtension NSExtensionPointIdentifier diff --git a/jest.config.js b/jest.config.js index 5b36e44c7581..13645e720c8e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -23,7 +23,7 @@ module.exports = { }, testEnvironment: 'jsdom', setupFiles: ['/jest/setup.ts', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'], - setupFilesAfterEnv: ['/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.js'], + setupFilesAfterEnv: ['/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.ts'], cacheDirectory: '/.jest-cache', moduleNameMapper: { '\\.(lottie)$': '/__mocks__/fileMock.ts', diff --git a/package-lock.json b/package-lock.json index bc373abcd9b0..3083c15c0196 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.50-5", + "version": "1.4.51-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.50-5", + "version": "1.4.51-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -52,7 +52,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#a8ed0f8e1be3a1e09016e07a74cfd13c85bbc167", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7bfd55f0ce75a37423119029fde58cfbe57086d9", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.10.1", @@ -8045,153 +8045,6 @@ "integrity": "sha512-C9Br1BQqm6io6lvYHptlLcOHbzlaqxp9tS35P8Qj3pdiiYRTzU3KPvZ61rQ+ZnZ4FOQ6MwPsKsmB8+6WHkAY6Q==", "license": "MIT" }, - "node_modules/@onfido/active-video-capture": { - "version": "0.28.6", - "resolved": "https://registry.npmjs.org/@onfido/active-video-capture/-/active-video-capture-0.28.6.tgz", - "integrity": "sha512-RFUeKaOSjj/amPp6VzhVkq/7kIkutEnnttT9n5KDeD3Vx8a09KD3a/xvxdQppveHlDAYsdBP6LrJwSSpjXiprg==", - "dependencies": { - "@mediapipe/face_detection": "^0.4.1646425229", - "@mediapipe/face_mesh": "^0.4.1633559619", - "@onfido/castor": "^2.2.2", - "@onfido/castor-icons": "^2.12.0", - "@tensorflow-models/face-detection": "^1.0.1", - "@tensorflow-models/face-landmarks-detection": "^1.0.2", - "@tensorflow/tfjs-backend-wasm": "3.20.0", - "@tensorflow/tfjs-backend-webgl": "3.20.0", - "@tensorflow/tfjs-converter": "3.20.0", - "@tensorflow/tfjs-core": "3.20.0", - "preact": "10.11.3", - "react-webcam": "^7.2.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@onfido/active-video-capture/node_modules/@tensorflow-models/face-landmarks-detection": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@tensorflow-models/face-landmarks-detection/-/face-landmarks-detection-1.0.5.tgz", - "integrity": "sha512-54XJPi8g29/MknJ33ZBrLsEzr9kw/dJtrJMMD3xrCrnRlfFQPIKQ5PI2Wml55Fz2p4U2hemzBB0/H+S94JddIQ==", - "dependencies": { - "rimraf": "^3.0.2" - }, - "peerDependencies": { - "@mediapipe/face_mesh": "~0.4.0", - "@tensorflow-models/face-detection": "~1.0.0", - "@tensorflow/tfjs-backend-webgl": "^3.12.0", - "@tensorflow/tfjs-converter": "^3.12.0", - "@tensorflow/tfjs-core": "^3.12.0" - } - }, - "node_modules/@onfido/active-video-capture/node_modules/@tensorflow/tfjs-backend-cpu": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-3.20.0.tgz", - "integrity": "sha512-gf075YaBLwSAAiUwa0D4GvYyUBhbJ1BVSivUNQmUfGKvIr2lIhF0qstBr033YTc3lhkbFSHEEPAHh/EfpqyjXQ==", - "dependencies": { - "@types/seedrandom": "^2.4.28", - "seedrandom": "^3.0.5" - }, - "engines": { - "yarn": ">= 1.3.2" - }, - "peerDependencies": { - "@tensorflow/tfjs-core": "3.20.0" - } - }, - "node_modules/@onfido/active-video-capture/node_modules/@tensorflow/tfjs-backend-wasm": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-wasm/-/tfjs-backend-wasm-3.20.0.tgz", - "integrity": "sha512-k+sDcrcPtGToLjKRffgtSqlcN4MC6g4hXWRarZfgvvyvFqpxVfVqrGYHGTirXdN47sKYhmcTSMvbM2quGaaQnA==", - "dependencies": { - "@tensorflow/tfjs-backend-cpu": "3.20.0", - "@types/emscripten": "~0.0.34" - }, - "peerDependencies": { - "@tensorflow/tfjs-core": "3.20.0" - } - }, - "node_modules/@onfido/active-video-capture/node_modules/@tensorflow/tfjs-backend-webgl": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-3.20.0.tgz", - "integrity": "sha512-SucbyQ08re3HvRgVfarRtKFIjNM4JvIAzcXmw4vaE/HrCtPEePkGO1VrmfQoN470EdUmGiwgqAjoyBvM2VOlVg==", - "dependencies": { - "@tensorflow/tfjs-backend-cpu": "3.20.0", - "@types/offscreencanvas": "~2019.3.0", - "@types/seedrandom": "^2.4.28", - "@types/webgl-ext": "0.0.30", - "@types/webgl2": "0.0.6", - "seedrandom": "^3.0.5" - }, - "engines": { - "yarn": ">= 1.3.2" - }, - "peerDependencies": { - "@tensorflow/tfjs-core": "3.20.0" - } - }, - "node_modules/@onfido/active-video-capture/node_modules/@tensorflow/tfjs-converter": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-3.20.0.tgz", - "integrity": "sha512-8EIYqtQwvSYw9GFNW2OFU8Qnl/FQF/kKAsQJoORYaZ419WJo+FIZWbAWDtCpJSAgkgoHH1jYWgV9H313cVmqxg==", - "peerDependencies": { - "@tensorflow/tfjs-core": "3.20.0" - } - }, - "node_modules/@onfido/active-video-capture/node_modules/@tensorflow/tfjs-core": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-3.20.0.tgz", - "integrity": "sha512-L16JyVA4a8jFJXFgB9/oYZxcGq/GfLypt5dMVTyedznARZZ9SiY/UMMbo3IKl9ZylG1dOVVTpjzV3EvBYfeJXw==", - "dependencies": { - "@types/long": "^4.0.1", - "@types/offscreencanvas": "~2019.3.0", - "@types/seedrandom": "^2.4.28", - "@types/webgl-ext": "0.0.30", - "@webgpu/types": "0.1.16", - "long": "4.0.0", - "node-fetch": "~2.6.1", - "seedrandom": "^3.0.5" - }, - "engines": { - "yarn": ">= 1.3.2" - } - }, - "node_modules/@onfido/active-video-capture/node_modules/@webgpu/types": { - "version": "0.1.16", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.16.tgz", - "integrity": "sha512-9E61voMP4+Rze02jlTXud++Htpjyyk8vw5Hyw9FGRrmhHQg2GqbuOfwf5Klrb8vTxc2XWI3EfO7RUHMpxTj26A==" - }, - "node_modules/@onfido/castor": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@onfido/castor/-/castor-2.3.0.tgz", - "integrity": "sha512-FkydkjedS6b2g3SqgZMYnVRZvUs/MkaEuXXJWG9+LNc7DMFT1K8smOnNuHzkiM3cJhXL6yAADdKE0mg+ZIrucQ==", - "dependencies": { - "@onfido/castor-tokens": "^1.0.0-beta.6", - "csstype": "^3.1.1" - }, - "peerDependencies": { - "@onfido/castor-icons": ">=1.0.0" - } - }, - "node_modules/@onfido/castor-icons": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/@onfido/castor-icons/-/castor-icons-2.22.0.tgz", - "integrity": "sha512-7OnCvu5xqVWcBLqovZyb99NP0oHw7sjkVYXZhi438i0U6Pgecrhu/14Gc/IN/kvgDxWj9qmiYdd0qdjNaVckrQ==", - "peerDependencies": { - "react": ">=17 || ^16.14 || ^15.7 || ^0.14.10" - } - }, - "node_modules/@onfido/castor-tokens": { - "version": "1.0.0-beta.6", - "resolved": "https://registry.npmjs.org/@onfido/castor-tokens/-/castor-tokens-1.0.0-beta.6.tgz", - "integrity": "sha512-MfwuSlNdM0Ay0cI3LLyqZGsHW0e1Y1R/0IdQKVU575PdWQx1Q/538aOZMo/a3/oSW0pMEgfOm+mNqPx057cvWA==" - }, - "node_modules/@onfido/opencv": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@onfido/opencv/-/opencv-2.1.1.tgz", - "integrity": "sha512-Bwo0YsZrrdm+p5hpNFZ7yrqNVWJxOUbQW9aWDEUtkDWUL+nX2RHIR6F4lBGVmbqnG24anadS/+nEvy80SwD3tQ==", - "dependencies": { - "mirada": "^0.0.15" - } - }, "node_modules/@onfido/react-native-sdk": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/@onfido/react-native-sdk/-/react-native-sdk-10.6.0.tgz", @@ -30717,8 +30570,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#a8ed0f8e1be3a1e09016e07a74cfd13c85bbc167", - "integrity": "sha512-3d/JHWgeS+LFPRahCAXdLwnBYQk4XUYybtgCm7VsdmMDtCeGUTksLsEY7F1Zqm+ULqZjmCtYwAi8IPKy0fsSOw==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#7bfd55f0ce75a37423119029fde58cfbe57086d9", + "integrity": "sha512-v6UnN9yAW6p2996Fvd4AZnMRnisVfjg6ijWzUQue/6JsjSY+MW10oP74hSjD6x32fRrNmMctjy6d5a79bQFdPA==", "license": "MIT", "dependencies": { "classnames": "2.5.0", diff --git a/package.json b/package.json index 1c4f23700bc4..dc5b261c3b40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.50-5", + "version": "1.4.51-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -52,13 +52,13 @@ "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", "symbolicate-release:ios": "scripts/release-profile.js --platform=ios", "symbolicate-release:android": "scripts/release-profile.js --platform=android", - "test:e2e": "ts-node tests/e2e/testRunner.js --config ./config.local.ts", - "test:e2e:dev": "ts-node tests/e2e/testRunner.js --config ./config.dev.js", + "test:e2e": "ts-node tests/e2e/testRunner.ts --config ./config.local.ts", + "test:e2e:dev": "ts-node tests/e2e/testRunner.ts --config ./config.dev.ts", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.js", "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1", - "e2e-test-runner-build": "ncc build tests/e2e/testRunner.js -o tests/e2e/dist/" + "e2e-test-runner-build": "ncc build tests/e2e/testRunner.ts -o tests/e2e/dist/" }, "dependencies": { "@dotlottie/react-player": "^1.6.3", @@ -103,7 +103,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#a8ed0f8e1be3a1e09016e07a74cfd13c85bbc167", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7bfd55f0ce75a37423119029fde58cfbe57086d9", "expo": "^50.0.3", "expo-av": "~13.10.4", "expo-image": "1.10.1", diff --git a/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch b/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch index 877521094cd4..c65ebbb98007 100644 --- a/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch +++ b/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch @@ -43,7 +43,7 @@ index 7558eb3..b7bb75e 100644 }) : STATE_TRANSITIONING_OR_BELOW_TOP; } + -+ const isHomeScreenAndNotOnTop = (route.name === 'BottomTabNavigator' || route.name === 'Settings_Root') && isScreenActive !== STATE_ON_TOP; ++ const isHomeScreenAndNotOnTop = (route.name === 'BottomTabNavigator' || route.name === 'Workspace_Initial') && isScreenActive !== STATE_ON_TOP; + const { headerShown = true, diff --git a/patches/react-native-reanimated+3.7.1+001+fix-boost-dependency.patch b/patches/react-native-reanimated+3.7.2+001+fix-boost-dependency.patch similarity index 100% rename from patches/react-native-reanimated+3.7.1+001+fix-boost-dependency.patch rename to patches/react-native-reanimated+3.7.2+001+fix-boost-dependency.patch diff --git a/patches/react-native-reanimated+3.7.1.patch b/patches/react-native-reanimated+3.7.2.patch similarity index 100% rename from patches/react-native-reanimated+3.7.1.patch rename to patches/react-native-reanimated+3.7.2.patch diff --git a/src/CONST.ts b/src/CONST.ts index a163c63404a7..d4fbd0ff6ef3 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1498,8 +1498,6 @@ const CONST = { ALPHABETIC_AND_LATIN_CHARS: /^[\p{Script=Latin} ]*$/u, NON_ALPHABETIC_AND_NON_LATIN_CHARS: /[^\p{Script=Latin}]/gu, ACCENT_LATIN_CHARS: /[\u00C0-\u017F]/g, - INVALID_DISPLAY_NAME_LHN: /[^\p{L}\p{N}\u00C0-\u017F\s-]/gu, - INVALID_DISPLAY_NAME_ONLY_LHN: /^[^\p{L}\p{N}\u00C0-\u017F]$/gu, POSITIVE_INTEGER: /^\d+$/, PO_BOX: /\b[P|p]?(OST|ost)?\.?\s*[O|o|0]?(ffice|FFICE)?\.?\s*[B|b][O|o|0]?[X|x]?\.?\s+[#]?(\d+)\b/, ANY_VALUE: /^.+$/, @@ -1663,6 +1661,8 @@ const CONST = { LOGIN_CHARACTER_LIMIT: 254, CATEGORY_NAME_LIMIT: 256, + TAG_NAME_LIMIT: 256, + TITLE_CHARACTER_LIMIT: 100, DESCRIPTION_LIMIT: 500, diff --git a/src/Expensify.tsx b/src/Expensify.tsx index f822862ec434..5681be838ca8 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -183,12 +183,12 @@ function Expensify({ // If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report Linking.getInitialURL().then((url) => { setInitialUrl(url); - Report.openReportFromDeepLink(url ?? '', isAuthenticated); + Report.openReportFromDeepLink(url ?? ''); }); // Open chat report from a deep link (only mobile native) Linking.addEventListener('url', (state) => { - Report.openReportFromDeepLink(state.url, isAuthenticated); + Report.openReportFromDeepLink(state.url); }); return () => { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b9e7c4d5d274..8c48cbad561f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -332,6 +332,8 @@ const ONYXKEYS = { WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', WORKSPACE_CATEGORY_CREATE_FORM: 'workspaceCategoryCreate', WORKSPACE_CATEGORY_CREATE_FORM_DRAFT: 'workspaceCategoryCreateDraft', + WORKSPACE_TAG_CREATE_FORM: 'workspaceTagCreate', + WORKSPACE_TAG_CREATE_FORM_DRAFT: 'workspaceTagCreateDraft', WORKSPACE_SETTINGS_FORM_DRAFT: 'workspaceSettingsFormDraft', WORKSPACE_DESCRIPTION_FORM: 'workspaceDescriptionForm', WORKSPACE_DESCRIPTION_FORM_DRAFT: 'workspaceDescriptionFormDraft', @@ -416,6 +418,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: FormTypes.AddDebitCardForm; [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: FormTypes.WorkspaceSettingsForm; [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_CREATE_FORM]: FormTypes.WorkspaceCategoryCreateForm; + [ONYXKEYS.FORMS.WORKSPACE_TAG_CREATE_FORM]: FormTypes.WorkspaceTagCreateForm; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index d9f0c6658a2b..defb945ba8c2 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -450,140 +450,148 @@ const ROUTES = { WORKSPACE_NEW: 'workspace/new', WORKSPACE_NEW_ROOM: 'workspace/new-room', WORKSPACE_INITIAL: { - route: 'workspace/:policyID', - getRoute: (policyID: string) => `workspace/${policyID}` as const, + route: 'settings/workspaces/:policyID', + getRoute: (policyID: string) => `settings/workspaces/${policyID}` as const, }, WORKSPACE_INVITE: { - route: 'workspace/:policyID/invite', - getRoute: (policyID: string) => `workspace/${policyID}/invite` as const, + route: 'settings/workspaces/:policyID/invite', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/invite` as const, }, WORKSPACE_INVITE_MESSAGE: { - route: 'workspace/:policyID/invite-message', - getRoute: (policyID: string) => `workspace/${policyID}/invite-message` as const, + route: 'settings/workspaces/:policyID/invite-message', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/invite-message` as const, }, WORKSPACE_PROFILE: { - route: 'workspace/:policyID/profile', - getRoute: (policyID: string) => `workspace/${policyID}/profile` as const, + route: 'settings/workspaces/:policyID/profile', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile` as const, }, WORKSPACE_PROFILE_CURRENCY: { - route: 'workspace/:policyID/profile/currency', - getRoute: (policyID: string) => `workspace/${policyID}/profile/currency` as const, + route: 'settings/workspaces/:policyID/profile/currency', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/currency` as const, }, WORKSPACE_PROFILE_NAME: { - route: 'workspace/:policyID/profile/name', - getRoute: (policyID: string) => `workspace/${policyID}/profile/name` as const, + route: 'settings/workspaces/:policyID/profile/name', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/name` as const, }, WORKSPACE_PROFILE_DESCRIPTION: { - route: 'workspace/:policyID/profile/description', - getRoute: (policyID: string) => `workspace/${policyID}/profile/description` as const, + route: 'settings/workspaces/:policyID/profile/description', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/description` as const, }, WORKSPACE_PROFILE_SHARE: { - route: 'workspace/:policyID/profile/share', - getRoute: (policyID: string) => `workspace/${policyID}/profile/share` as const, + route: 'settings/workspaces/:policyID/profile/share', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/share` as const, }, WORKSPACE_AVATAR: { - route: 'workspace/:policyID/avatar', - getRoute: (policyID: string) => `workspace/${policyID}/avatar` as const, + route: 'settings/workspaces/:policyID/avatar', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/avatar` as const, }, WORKSPACE_JOIN_USER: { - route: 'workspace/:policyID/join', - getRoute: (policyID: string, inviterEmail: string) => `workspace/${policyID}/join?email=${inviterEmail}` as const, + route: 'settings/workspaces/:policyID/join', + getRoute: (policyID: string, inviterEmail: string) => `settings/workspaces/${policyID}/join?email=${inviterEmail}` as const, }, WORKSPACE_SETTINGS_CURRENCY: { - route: 'workspace/:policyID/settings/currency', - getRoute: (policyID: string) => `workspace/${policyID}/settings/currency` as const, + route: 'settings/workspaces/:policyID/settings/currency', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/settings/currency` as const, }, WORKSPACE_WORKFLOWS: { - route: 'workspace/:policyID/workflows', - getRoute: (policyID: string) => `workspace/${policyID}/workflows` as const, + route: 'settings/workspaces/:policyID/workflows', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/workflows` as const, + }, + WORKSPACE_WORKFLOWS_PAYER: { + route: 'workspace/:policyID/settings/workflows/payer', + getRoute: (policyId: string) => `workspace/${policyId}/settings/workflows/payer` as const, }, WORKSPACE_WORKFLOWS_APPROVER: { - route: 'workspace/:policyID/settings/workflows/approver', - getRoute: (policyId: string) => `workspace/${policyId}/settings/workflows/approver` as const, + route: 'settings/workspaces/:policyID/settings/workflows/approver', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/settings/workflows/approver` as const, }, WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY: { - route: 'workspace/:policyID/settings/workflows/auto-reporting-frequency', - getRoute: (policyID: string) => `workspace/${policyID}/settings/workflows/auto-reporting-frequency` as const, + route: 'settings/workspaces/:policyID/settings/workflows/auto-reporting-frequency', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/settings/workflows/auto-reporting-frequency` as const, }, WORKSPACE_WORKFLOWS_AUTOREPORTING_MONTHLY_OFFSET: { - route: 'workspace/:policyID/settings/workflows/auto-reporting-frequency/monthly-offset', - getRoute: (policyID: string) => `workspace/${policyID}/settings/workflows/auto-reporting-frequency/monthly-offset` as const, + route: 'settings/workspaces/:policyID/settings/workflows/auto-reporting-frequency/monthly-offset', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/settings/workflows/auto-reporting-frequency/monthly-offset` as const, }, WORKSPACE_CARD: { - route: 'workspace/:policyID/card', - getRoute: (policyID: string) => `workspace/${policyID}/card` as const, + route: 'settings/workspaces/:policyID/card', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/card` as const, }, WORKSPACE_REIMBURSE: { - route: 'workspace/:policyID/reimburse', - getRoute: (policyID: string) => `workspace/${policyID}/reimburse` as const, + route: 'settings/workspaces/:policyID/reimburse', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/reimburse` as const, }, WORKSPACE_RATE_AND_UNIT: { - route: 'workspace/:policyID/rateandunit', - getRoute: (policyID: string) => `workspace/${policyID}/rateandunit` as const, + route: 'settings/workspaces/:policyID/rateandunit', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rateandunit` as const, }, WORKSPACE_RATE_AND_UNIT_RATE: { - route: 'workspace/:policyID/rateandunit/rate', - getRoute: (policyID: string) => `workspace/${policyID}/rateandunit/rate` as const, + route: 'settings/workspaces/:policyID/rateandunit/rate', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rateandunit/rate` as const, }, WORKSPACE_RATE_AND_UNIT_UNIT: { - route: 'workspace/:policyID/rateandunit/unit', - getRoute: (policyID: string) => `workspace/${policyID}/rateandunit/unit` as const, + route: 'settings/workspaces/:policyID/rateandunit/unit', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rateandunit/unit` as const, }, WORKSPACE_BILLS: { - route: 'workspace/:policyID/bills', - getRoute: (policyID: string) => `workspace/${policyID}/bills` as const, + route: 'settings/workspaces/:policyID/bills', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/bills` as const, }, WORKSPACE_INVOICES: { - route: 'workspace/:policyID/invoices', - getRoute: (policyID: string) => `workspace/${policyID}/invoices` as const, + route: 'settings/workspaces/:policyID/invoices', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/invoices` as const, }, WORKSPACE_TRAVEL: { - route: 'workspace/:policyID/travel', - getRoute: (policyID: string) => `workspace/${policyID}/travel` as const, + route: 'settings/workspaces/:policyID/travel', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/travel` as const, }, WORKSPACE_MEMBERS: { - route: 'workspace/:policyID/members', - getRoute: (policyID: string) => `workspace/${policyID}/members` as const, + route: 'settings/workspaces/:policyID/members', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/members` as const, }, WORKSPACE_CATEGORIES: { - route: 'workspace/:policyID/categories', - getRoute: (policyID: string) => `workspace/${policyID}/categories` as const, + route: 'settings/workspaces/:policyID/categories', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories` as const, }, WORKSPACE_CATEGORY_SETTINGS: { - route: 'workspace/:policyID/categories/:categoryName', - getRoute: (policyID: string, categoryName: string) => `workspace/${policyID}/categories/${encodeURI(categoryName)}` as const, + route: 'settings/workspaces/:policyID/categories/:categoryName', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURI(categoryName)}` as const, }, WORKSPACE_CATEGORIES_SETTINGS: { - route: 'workspace/:policyID/categories/settings', - getRoute: (policyID: string) => `workspace/${policyID}/categories/settings` as const, + route: 'settings/workspaces/:policyID/categories/settings', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories/settings` as const, }, WORKSPACE_MORE_FEATURES: { route: 'workspace/:policyID/more-features', getRoute: (policyID: string) => `workspace/${policyID}/more-features` as const, }, WORKSPACE_CATEGORY_CREATE: { - route: 'workspace/:policyID/categories/new', - getRoute: (policyID: string) => `workspace/${policyID}/categories/new` as const, + route: 'settings/workspaces/:policyID/categories/new', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories/new` as const, }, WORKSPACE_TAGS: { - route: 'workspace/:policyID/tags', - getRoute: (policyID: string) => `workspace/${policyID}/tags` as const, + route: 'settings/workspaces/:policyID/tags', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags` as const, + }, + WORKSPACE_TAG_CREATE: { + route: 'settings/workspaces/:policyID/tags/new', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags/new` as const, }, WORKSPACE_TAGS_SETTINGS: { - route: 'workspace/:policyID/tags/settings', - getRoute: (policyID: string) => `workspace/${policyID}/tags/settings` as const, + route: 'settings/workspaces/:policyID/tags/settings', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags/settings` as const, }, WORKSPACE_EDIT_TAGS: { - route: 'workspace/:policyID/tags/edit', - getRoute: (policyID: string) => `workspace/${policyID}/tags/edit` as const, + route: 'settings/workspaces/:policyID/tags/edit', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags/edit` as const, }, WORKSPACE_MEMBER_DETAILS: { - route: 'workspace/:policyID/members/:accountID', - getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`workspace/${policyID}/members/${accountID}`, backTo), + route: 'settings/workspaces/:policyID/members/:accountID', + getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/members/${accountID}`, backTo), }, WORKSPACE_MEMBER_ROLE_SELECTION: { - route: 'workspace/:policyID/members/:accountID/role-selection', - getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`workspace/${policyID}/members/${accountID}/role-selection`, backTo), + route: 'settings/workspaces/:policyID/members/:accountID/role-selection', + getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/members/${accountID}/role-selection`, backTo), }, WORKSPACE_DISTANCE_RATES: { route: 'workspace/:policyID/distance-rates', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index a0e06b98da2b..4db5fd9115a5 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -22,6 +22,7 @@ const SCREENS = { VALIDATE_LOGIN: 'ValidateLogin', UNLINK_LOGIN: 'UnlinkLogin', SETTINGS_CENTRAL_PANE: 'SettingsCentralPane', + WORKSPACES_CENTRAL_PANE: 'WorkspacesCentralPane', SETTINGS: { ROOT: 'Settings_Root', SHARE_CODE: 'Settings_Share_Code', @@ -218,8 +219,10 @@ const SCREENS = { TAGS: 'Workspace_Tags', TAGS_SETTINGS: 'Tags_Settings', TAGS_EDIT: 'Tags_Edit', + TAG_CREATE: 'Tag_Create', CURRENCY: 'Workspace_Profile_Currency', WORKFLOWS: 'Workspace_Workflows', + WORKFLOWS_PAYER: 'Workspace_Workflows_Payer', WORKFLOWS_APPROVER: 'Workspace_Workflows_Approver', WORKFLOWS_AUTO_REPORTING_FREQUENCY: 'Workspace_Workflows_Auto_Reporting_Frequency', WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET: 'Workspace_Workflows_Auto_Reporting_Monthly_Offset', diff --git a/src/components/AvatarSkeleton.tsx b/src/components/AvatarSkeleton.tsx index 0887830aa07a..a6781448c3ba 100644 --- a/src/components/AvatarSkeleton.tsx +++ b/src/components/AvatarSkeleton.tsx @@ -1,21 +1,24 @@ import React from 'react'; import {Circle} from 'react-native-svg'; import useTheme from '@hooks/useTheme'; +import variables from '@styles/variables'; import SkeletonViewContentLoader from './SkeletonViewContentLoader'; function AvatarSkeleton() { const theme = useTheme(); + const skeletonCircleRadius = variables.componentSizeSmall / 2; + return ( ); diff --git a/src/components/AvatarWithIndicator.tsx b/src/components/AvatarWithIndicator.tsx index 2fd733d4b072..42b91b3d2d71 100644 --- a/src/components/AvatarWithIndicator.tsx +++ b/src/components/AvatarWithIndicator.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import * as UserUtils from '@libs/UserUtils'; +import CONST from '@src/CONST'; import Avatar from './Avatar'; import AvatarSkeleton from './AvatarSkeleton'; import * as Expensicons from './Icon/Expensicons'; @@ -33,6 +34,7 @@ function AvatarWithIndicator({source, tooltipText = '', fallbackIcon = Expensico ) : ( <> diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index fcf1baaa6aed..798369292958 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -15,7 +15,7 @@ type WorkspaceDistanceRatesBulkActionType = DeepValueOf = { value: TValueType; text: string; - icon: IconAsset; + icon?: IconAsset; iconWidth?: number; iconHeight?: number; iconDescription?: string; @@ -58,7 +58,7 @@ type ButtonWithDropdownMenuProps = { anchorAlignment?: AnchorAlignment; /* ref for the button */ - buttonRef: RefObject; + buttonRef?: RefObject; /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */ enterKeyEventListenerPriority?: number; diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx index 7e7720b57a6e..5445816f067b 100644 --- a/src/components/Checkbox.tsx +++ b/src/components/Checkbox.tsx @@ -127,3 +127,5 @@ function Checkbox( Checkbox.displayName = 'Checkbox'; export default forwardRef(Checkbox); + +export type {CheckboxProps}; diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx index 7f05b45bca30..17c5097b8154 100644 --- a/src/components/ConfirmedRoute.tsx +++ b/src/components/ConfirmedRoute.tsx @@ -25,13 +25,13 @@ type ConfirmedRoutePropsOnyxProps = { type ConfirmedRouteProps = ConfirmedRoutePropsOnyxProps & { /** Transaction that stores the distance request data */ - transaction: Transaction; + transaction: OnyxEntry; }; function ConfirmedRoute({mapboxAccessToken, transaction}: ConfirmedRouteProps) { const {isOffline} = useNetwork(); - const {route0: route} = transaction.routes ?? {}; - const waypoints = transaction.comment?.waypoints ?? {}; + const {route0: route} = transaction?.routes ?? {}; + const waypoints = transaction?.comment?.waypoints ?? {}; const coordinates = route?.geometry?.coordinates ?? []; const theme = useTheme(); const styles = useThemeStyles(); diff --git a/src/components/EReceipt.tsx b/src/components/EReceipt.tsx index 183d88ba1c6a..40f5d242d005 100644 --- a/src/components/EReceipt.tsx +++ b/src/components/EReceipt.tsx @@ -105,3 +105,4 @@ export default withOnyx({ key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, }, })(EReceipt); +export type {EReceiptProps, EReceiptOnyxProps}; diff --git a/src/components/EReceiptThumbnail.tsx b/src/components/EReceiptThumbnail.tsx index f5f9b5fc5f06..023dcc16e696 100644 --- a/src/components/EReceiptThumbnail.tsx +++ b/src/components/EReceiptThumbnail.tsx @@ -114,3 +114,4 @@ export default withOnyx({ key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, }, })(EReceiptThumbnail); +export type {EReceiptThumbnailProps, EReceiptThumbnailOnyxProps}; diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index 88938f31cd79..b9c52ad397ec 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -104,13 +104,13 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo }; return ( - - - + + + { fabPressable.current = el ?? null; @@ -136,9 +136,9 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo /> - - - + + + ); } diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index ad09b68a5f39..ee3b3607401e 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -394,3 +394,5 @@ export default withOnyx({ key: (props) => `${props.formID}Draft` as any, }, })(forwardRef(FormProvider)) as (props: Omit, keyof FormProviderOnyxProps>) => ReactNode; + +export type {FormProviderProps}; diff --git a/src/components/FormAlertWithSubmitButton.tsx b/src/components/FormAlertWithSubmitButton.tsx index 9968bb0e0772..270d476f4f79 100644 --- a/src/components/FormAlertWithSubmitButton.tsx +++ b/src/components/FormAlertWithSubmitButton.tsx @@ -120,3 +120,5 @@ function FormAlertWithSubmitButton({ FormAlertWithSubmitButton.displayName = 'FormAlertWithSubmitButton'; export default FormAlertWithSubmitButton; + +export type {FormAlertWithSubmitButtonProps}; diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/growlNotificationContainerPropTypes.js b/src/components/GrowlNotification/GrowlNotificationContainer/growlNotificationContainerPropTypes.js deleted file mode 100644 index 2432d1b1748c..000000000000 --- a/src/components/GrowlNotification/GrowlNotificationContainer/growlNotificationContainerPropTypes.js +++ /dev/null @@ -1,12 +0,0 @@ -import PropTypes from 'prop-types'; -import {Animated} from 'react-native'; - -const propTypes = { - /** GrowlNotification content */ - children: PropTypes.node.isRequired, - - /** GrowlNotification Y postion, required to show or hide with fling animation */ - translateY: PropTypes.instanceOf(Animated.Value).isRequired, -}; - -export default propTypes; diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.js b/src/components/GrowlNotification/GrowlNotificationContainer/index.js deleted file mode 100644 index ccc404d415d7..000000000000 --- a/src/components/GrowlNotification/GrowlNotificationContainer/index.js +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import {Animated} from 'react-native'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import useThemeStyles from '@hooks/useThemeStyles'; -import growlNotificationContainerPropTypes from './growlNotificationContainerPropTypes'; - -const propTypes = { - ...growlNotificationContainerPropTypes, - ...windowDimensionsPropTypes, -}; - -function GrowlNotificationContainer(props) { - const styles = useThemeStyles(); - return ( - - {props.children} - - ); -} - -GrowlNotificationContainer.propTypes = propTypes; -GrowlNotificationContainer.displayName = 'GrowlNotificationContainer'; - -export default withWindowDimensions(GrowlNotificationContainer); diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.native.js b/src/components/GrowlNotification/GrowlNotificationContainer/index.native.js deleted file mode 100644 index 207033f8fac2..000000000000 --- a/src/components/GrowlNotification/GrowlNotificationContainer/index.native.js +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import {Animated} from 'react-native'; -import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import growlNotificationContainerPropTypes from './growlNotificationContainerPropTypes'; - -const propTypes = { - ...growlNotificationContainerPropTypes, -}; - -function GrowlNotificationContainer(props) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const insets = useSafeAreaInsets; - - return ( - - {props.children} - - ); -} - -GrowlNotificationContainer.propTypes = propTypes; -GrowlNotificationContainer.displayName = 'GrowlNotificationContainer'; - -export default GrowlNotificationContainer; diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx b/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx new file mode 100644 index 000000000000..efd143c9487c --- /dev/null +++ b/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import {Animated} from 'react-native'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type GrowlNotificationContainerProps from './types'; + +function GrowlNotificationContainer({children, translateY}: GrowlNotificationContainerProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const insets = useSafeAreaInsets(); + + return {children}; +} + +GrowlNotificationContainer.displayName = 'GrowlNotificationContainer'; + +export default GrowlNotificationContainer; diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx b/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx new file mode 100644 index 000000000000..3bbd0303906d --- /dev/null +++ b/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import {Animated} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import type GrowlNotificationContainerProps from './types'; + +function GrowlNotificationContainer({children, translateY}: GrowlNotificationContainerProps) { + const styles = useThemeStyles(); + const {isSmallScreenWidth} = useWindowDimensions(); + + return ( + + {children} + + ); +} + +GrowlNotificationContainer.displayName = 'GrowlNotificationContainer'; + +export default GrowlNotificationContainer; diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/types.ts b/src/components/GrowlNotification/GrowlNotificationContainer/types.ts new file mode 100644 index 000000000000..91a48437dbd9 --- /dev/null +++ b/src/components/GrowlNotification/GrowlNotificationContainer/types.ts @@ -0,0 +1,8 @@ +import type {Animated} from 'react-native'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +type GrowlNotificationContainerProps = ChildrenProps & { + translateY: Animated.Value; +}; + +export default GrowlNotificationContainerProps; diff --git a/src/components/GrowlNotification/index.js b/src/components/GrowlNotification/index.tsx similarity index 82% rename from src/components/GrowlNotification/index.js rename to src/components/GrowlNotification/index.tsx index ed0dd302f705..d0846dcf7a42 100644 --- a/src/components/GrowlNotification/index.js +++ b/src/components/GrowlNotification/index.tsx @@ -1,6 +1,8 @@ +import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {Animated, View} from 'react-native'; import {Directions, Gesture, GestureDetector} from 'react-native-gesture-handler'; +import type {SvgProps} from 'react-native-svg'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Pressables from '@components/Pressable'; @@ -8,6 +10,7 @@ import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Growl from '@libs/Growl'; +import type {GrowlRef} from '@libs/Growl'; import useNativeDriver from '@libs/useNativeDriver'; import CONST from '@src/CONST'; import GrowlNotificationContainer from './GrowlNotificationContainer'; @@ -16,15 +19,29 @@ const INACTIVE_POSITION_Y = -255; const PressableWithoutFeedback = Pressables.PressableWithoutFeedback; -function GrowlNotification(_, ref) { +function GrowlNotification(_: unknown, ref: ForwardedRef) { const translateY = useRef(new Animated.Value(INACTIVE_POSITION_Y)).current; const [bodyText, setBodyText] = useState(''); const [type, setType] = useState('success'); - const [duration, setDuration] = useState(); + const [duration, setDuration] = useState(); const theme = useTheme(); const styles = useThemeStyles(); - const types = { + type GrowlIconTypes = Record< + /** String representing the growl type, all type strings + * for growl notifications are stored in CONST.GROWL + */ + string, + { + /** Expensicon for the page */ + icon: React.FC; + + /** Color for the icon (should be from theme) */ + iconColor: string; + } + >; + + const types: GrowlIconTypes = { [CONST.GROWL.SUCCESS]: { icon: Expensicons.Checkmark, iconColor: theme.success, @@ -46,7 +63,7 @@ function GrowlNotification(_, ref) { * @param {String} type * @param {Number} duration */ - const show = useCallback((text, growlType, growlDuration) => { + const show = useCallback((text: string, growlType: string, growlDuration: number) => { setBodyText(text); setType(growlType); setDuration(growlDuration); @@ -61,7 +78,6 @@ function GrowlNotification(_, ref) { (val = INACTIVE_POSITION_Y) => { Animated.spring(translateY, { toValue: val, - duration: 80, useNativeDriver, }).start(); }, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 5d8c0f6ef81e..0327b6bc6f56 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -1,3 +1,4 @@ +import Str from 'expensify-common/lib/str'; import cloneDeep from 'lodash/cloneDeep'; import isEmpty from 'lodash/isEmpty'; import React from 'react'; @@ -65,10 +66,11 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona // We need to remove the LTR unicode and leading @ from data as it is not part of the login displayNameOrLogin = tnodeClone.data.replace(CONST.UNICODE.LTR, '').slice(1); // We need to replace tnode.data here because we will pass it to TNodeChildrenRenderer below - asMutable(tnodeClone).data = tnodeClone.data.replace(displayNameOrLogin, getMentionDisplayText(displayNameOrLogin, htmlAttributeAccountID)); + asMutable(tnodeClone).data = tnodeClone.data.replace(displayNameOrLogin, Str.removeSMSDomain(getMentionDisplayText(displayNameOrLogin, htmlAttributeAccountID))); accountID = PersonalDetailsUtils.getAccountIDsByLogins([displayNameOrLogin])?.[0]; navigationRoute = ROUTES.DETAILS.getRoute(displayNameOrLogin); + displayNameOrLogin = Str.removeSMSDomain(displayNameOrLogin); } else { // If neither an account ID or email is provided, don't render anything return null; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 25532107016f..68c445cc944c 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -55,3 +55,5 @@ function Header({title = '', subtitle = '', textStyles = [], shouldShowEnvironme Header.displayName = 'Header'; export default Header; + +export type {HeaderProps}; diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 3a1c35d46c94..21f3e9a3b605 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {Keyboard, StyleSheet, View} from 'react-native'; +import Avatar from '@components/Avatar'; import AvatarWithDisplayName from '@components/AvatarWithDisplayName'; import Header from '@components/Header'; import Icon from '@components/Icon'; @@ -32,7 +33,8 @@ function HeaderWithBackButton({ onThreeDotsButtonPress = () => {}, report = null, policy, - shouldShowAvatarWithDisplay = false, + policyAvatar, + shouldShowReportAvatarWithDisplay = false, shouldShowBackButton = true, shouldShowBorderBottom = false, shouldShowCloseButton = false, @@ -58,6 +60,7 @@ function HeaderWithBackButton({ shouldOverlay = false, singleExecution = (func) => func, shouldNavigateToTopMostReport = false, + style, }: HeaderWithBackButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -81,6 +84,7 @@ function HeaderWithBackButton({ shouldShowBorderBottom && styles.borderBottom, shouldShowBackButton && styles.pl2, shouldOverlay && StyleSheet.absoluteFillObject, + style, ]} > @@ -118,7 +122,15 @@ function HeaderWithBackButton({ additionalStyles={[styles.mr2]} /> )} - {shouldShowAvatarWithDisplay ? ( + {policyAvatar && ( + + )} + {shouldShowReportAvatarWithDisplay ? ( & { /** Data to display a step counter in the header */ stepCounter?: StepCounterParams; - /** Whether we should show an avatar */ - shouldShowAvatarWithDisplay?: boolean; + /** Whether we should show a report avatar */ + shouldShowReportAvatarWithDisplay?: boolean; /** Parent report, if provided it will override props.report for AvatarWithDisplay */ parentReport?: OnyxEntry; @@ -121,6 +123,12 @@ type HeaderWithBackButtonProps = Partial & { /** Whether we should overlay the 3 dots menu */ shouldOverlayDots?: boolean; + + /** Policy avatar to display in the header */ + policyAvatar?: Icon; + + /** Additional styles to add to the component */ + style?: StyleProp; }; export type {ThreeDotsMenuItem}; diff --git a/src/components/Indicator.tsx b/src/components/Indicator.tsx index 1420a6abe189..e3d226a17999 100644 --- a/src/components/Indicator.tsx +++ b/src/components/Indicator.tsx @@ -1,17 +1,24 @@ import React from 'react'; import {StyleSheet, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as UserUtils from '@libs/UserUtils'; import * as PaymentMethods from '@userActions/PaymentMethods'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {BankAccountList, FundList, LoginList, UserWallet, WalletTerms} from '@src/types/onyx'; +import type {BankAccountList, FundList, LoginList, Policy, PolicyMembers, ReimbursementAccount, UserWallet, WalletTerms} from '@src/types/onyx'; type CheckingMethod = () => boolean; type IndicatorOnyxProps = { + /** The employee list of all policies (coming from Onyx) */ + allPolicyMembers: OnyxCollection; + + /** All the user's policies (from Onyx via withFullPolicy) */ + policies: OnyxCollection; + /** List of bank accounts */ bankAccountList: OnyxEntry; @@ -21,6 +28,9 @@ type IndicatorOnyxProps = { /** The user's wallet (coming from Onyx) */ userWallet: OnyxEntry; + /** Bank account attached to free plan */ + reimbursementAccount: OnyxEntry; + /** Information about the user accepting the terms for payments */ walletTerms: OnyxEntry; @@ -30,16 +40,25 @@ type IndicatorOnyxProps = { type IndicatorProps = IndicatorOnyxProps; -function Indicator({bankAccountList, fundList, userWallet, walletTerms, loginList}: IndicatorOnyxProps) { +function Indicator({reimbursementAccount, allPolicyMembers, policies, bankAccountList, fundList, userWallet, walletTerms, loginList}: IndicatorOnyxProps) { const theme = useTheme(); const styles = useThemeStyles(); + // If a policy was just deleted from Onyx, then Onyx will pass a null value to the props, and + // those should be cleaned out before doing any error checking + const cleanPolicies = Object.fromEntries(Object.entries(policies ?? {}).filter(([, policy]) => policy?.id)); + const cleanAllPolicyMembers = Object.fromEntries(Object.entries(allPolicyMembers ?? {}).filter(([, policyMembers]) => !!policyMembers)); + // All of the error & info-checking methods are put into an array. This is so that using _.some() will return // early as soon as the first error / info condition is returned. This makes the checks very efficient since // we only care if a single error / info condition exists anywhere. const errorCheckingMethods: CheckingMethod[] = [ () => Object.keys(userWallet?.errors ?? {}).length > 0, () => PaymentMethods.hasPaymentMethodError(bankAccountList, fundList), + () => Object.values(cleanPolicies).some(PolicyUtils.hasPolicyError), + () => Object.values(cleanPolicies).some(PolicyUtils.hasCustomUnitsError), + () => Object.values(cleanAllPolicyMembers).some(PolicyUtils.hasPolicyMemberError), + () => Object.keys(reimbursementAccount?.errors ?? {}).length > 0, () => !!loginList && UserUtils.hasLoginListError(loginList), // Wallet term errors that are not caused by an IOU (we show the red brick indicator for those in the LHN instead) @@ -58,9 +77,19 @@ function Indicator({bankAccountList, fundList, userWallet, walletTerms, loginLis Indicator.displayName = 'Indicator'; export default withOnyx({ + allPolicyMembers: { + key: ONYXKEYS.COLLECTION.POLICY_MEMBERS, + }, + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, bankAccountList: { key: ONYXKEYS.BANK_ACCOUNT_LIST, }, + // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM + reimbursementAccount: { + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + }, fundList: { key: ONYXKEYS.FUND_LIST, }, diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index f5545f402b14..27f424ad1b70 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -32,7 +32,6 @@ function LHNOptionsList({ draftComments = {}, transactionViolations = {}, onFirstItemRendered = () => {}, - reportIDsWithErrors = {}, }: LHNOptionsListProps) { const styles = useThemeStyles(); const {canUseViolations} = usePermissions(); @@ -64,7 +63,6 @@ function LHNOptionsList({ const itemComment = draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] ?? ''; const sortedReportActions = ReportActionsUtils.getSortedReportActionsForDisplay(itemReportActions); const lastReportAction = sortedReportActions[0]; - const reportErrors = reportIDsWithErrors[reportID] ?? {}; // Get the transaction for the last report action let lastReportActionTransactionID = ''; @@ -93,7 +91,6 @@ function LHNOptionsList({ transactionViolations={transactionViolations} canUseViolations={canUseViolations} onLayout={onLayoutItem} - reportErrors={reportErrors} /> ); }, @@ -112,7 +109,6 @@ function LHNOptionsList({ transactionViolations, canUseViolations, onLayoutItem, - reportIDsWithErrors, ], ); diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index a3394190d0c1..a18d5a8ec1ec 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -28,7 +28,6 @@ function OptionRowLHNData({ lastReportActionTransaction = {}, transactionViolations, canUseViolations, - reportErrors, ...propsToForward }: OptionRowLHNDataProps) { const reportID = propsToForward.reportID; @@ -41,11 +40,11 @@ function OptionRowLHNData({ // Note: ideally we'd have this as a dependent selector in onyx! const item = SidebarUtils.getOptionData({ report: fullReport, + reportActions, personalDetails, preferredLocale: preferredLocale ?? CONST.LOCALES.DEFAULT, policy, parentReportAction, - reportErrors, hasViolations: !!hasViolations, }); if (deepEqual(item, optionItemRef.current)) { @@ -70,7 +69,6 @@ function OptionRowLHNData({ transactionViolations, canUseViolations, receiptTransactions, - reportErrors, ]); useEffect(() => { diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index c122ab018392..58bea97f04c9 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -7,7 +7,6 @@ import type {CurrentReportIDContextValue} from '@components/withCurrentReportID' import type CONST from '@src/CONST'; import type {OptionData} from '@src/libs/ReportUtils'; import type {Locale, PersonalDetailsList, Policy, Report, ReportAction, ReportActions, Transaction, TransactionViolation} from '@src/types/onyx'; -import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; type OptionMode = ValueOf; @@ -59,9 +58,6 @@ type CustomLHNOptionsListProps = { /** Callback to fire when the list is laid out */ onFirstItemRendered: () => void; - - /** Report IDs with errors mapping to their corresponding error objects */ - reportIDsWithErrors: Record; }; type LHNOptionsListProps = CustomLHNOptionsListProps & CurrentReportIDContextValue & LHNOptionsListOnyxProps; @@ -117,9 +113,6 @@ type OptionRowLHNDataProps = { /** Callback to execute when the OptionList lays out */ onLayout?: (event: LayoutChangeEvent) => void; - - /** The report errors */ - reportErrors: OnyxCommon.Errors | undefined; }; type OptionRowLHNProps = { diff --git a/src/components/Lottie/index.tsx b/src/components/Lottie/index.tsx index 5c672cf7cab6..08e7613dc7a9 100644 --- a/src/components/Lottie/index.tsx +++ b/src/components/Lottie/index.tsx @@ -4,6 +4,7 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef} from 'react'; import {View} from 'react-native'; import type DotLottieAnimation from '@components/LottieAnimations/types'; +import useAppState from '@hooks/useAppState'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -12,6 +13,7 @@ type Props = { } & Omit; function Lottie({source, webStyle, ...props}: Props, ref: ForwardedRef) { + const appState = useAppState(); const styles = useThemeStyles(); const [isError, setIsError] = React.useState(false); @@ -19,8 +21,10 @@ function Lottie({source, webStyle, ...props}: Props, ref: ForwardedRef; } diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index e0d6c39623ed..74fec2c606af 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -97,7 +97,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money return ( ; + +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; + + /** 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; +}; +type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & { /** Callback to inform parent modal of success */ - onConfirm: PropTypes.func, + onConfirm?: (selectedParticipants: Participant[]) => void; /** Callback to parent modal to send money */ - onSendMoney: PropTypes.func, + onSendMoney?: (paymentMethod: IouType | PaymentMethodType | undefined) => void; /** Callback to inform a participant is selected */ - onSelectParticipant: PropTypes.func, + onSelectParticipant?: (option: Participant) => void; /** Should we request a single or multiple participant selection from user */ - hasMultipleParticipants: PropTypes.bool.isRequired, + hasMultipleParticipants: boolean; /** IOU amount */ - iouAmount: PropTypes.number.isRequired, + iouAmount: number; /** IOU comment */ - iouComment: PropTypes.string, + iouComment?: string; /** IOU currency */ - iouCurrencyCode: PropTypes.string, + iouCurrencyCode?: string; /** IOU type */ - iouType: PropTypes.string, + iouType?: IouType; /** IOU date */ - iouCreated: PropTypes.string, + iouCreated?: string; /** IOU merchant */ - iouMerchant: PropTypes.string, + iouMerchant?: string; /** IOU Category */ - iouCategory: PropTypes.string, + iouCategory?: string; /** IOU Tag */ - iouTag: PropTypes.string, + iouTag?: string; /** IOU isBillable */ - iouIsBillable: PropTypes.bool, + iouIsBillable?: boolean; /** Callback to toggle the billable state */ - onToggleBillable: PropTypes.func, + onToggleBillable?: (isOn: boolean) => void; /** Selected participants from MoneyRequestModal with login / accountID */ - selectedParticipants: PropTypes.arrayOf(optionPropTypes).isRequired, + selectedParticipants: Participant[]; /** Payee of the money request with login */ - payeePersonalDetails: optionPropTypes, + payeePersonalDetails?: OnyxTypes.PersonalDetails; /** Can the participants be modified or not */ - canModifyParticipants: PropTypes.bool, + canModifyParticipants?: boolean; /** Should the list be read only, and not editable? */ - isReadOnly: PropTypes.bool, + isReadOnly?: boolean; /** Depending on expense report or personal IOU report, respective bank account route */ - bankAccountRoute: PropTypes.string, - - ...withCurrentUserPersonalDetailsPropTypes, - - /** Current user session */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, - }), + bankAccountRoute?: AllRoutes; /** The policyID of the request */ - policyID: PropTypes.string, + policyID?: string; /** The reportID of the request */ - reportID: PropTypes.string, + reportID?: string; /** File path of the receipt */ - receiptPath: PropTypes.string, + receiptPath?: string; /** File name of the receipt */ - receiptFilename: PropTypes.string, + receiptFilename?: string; /** List styles for OptionsSelector */ - listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + listStyles?: StyleProp; /** ID of the transaction that represents the money request */ - transactionID: PropTypes.string, + transactionID?: string; /** Transaction that represents the money request */ - transaction: transactionPropTypes, - - /** Unit and rate used for if the money request is a distance request */ - mileageRate: PropTypes.shape({ - /** Unit used to represent distance */ - unit: PropTypes.oneOf([CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]), - - /** Rate used to calculate the distance request amount */ - rate: PropTypes.number, - - /** The currency of the rate */ - currency: PropTypes.string, - }), + transaction?: OnyxEntry; /** Whether the money request is a distance request */ - isDistanceRequest: PropTypes.bool, + isDistanceRequest?: boolean; /** Whether the money request is a scan request */ - isScanRequest: PropTypes.bool, + isScanRequest?: boolean; /** Whether we're editing a split bill */ - isEditingSplitBill: PropTypes.bool, + isEditingSplitBill?: boolean; /** Whether we should show the amount, date, and merchant fields. */ - shouldShowSmartScanFields: PropTypes.bool, + shouldShowSmartScanFields?: boolean; /** A flag for verifying that the current report is a sub-report of a workspace chat */ - isPolicyExpenseChat: PropTypes.bool, + isPolicyExpenseChat?: boolean; - /* Onyx Props */ - /** Collection of categories attached to a policy */ - policyCategories: PropTypes.objectOf(categoryPropTypes), - - /** Collection of tags attached to a policy */ - policyTags: tagPropTypes, + /** Whether smart scan failed */ + hasSmartScanFailed?: boolean; - /* Onyx Props */ - /** The policy of the report */ - policy: policyPropTypes.policy, - - /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ - iou: iouPropTypes, + /** The ID of the report action */ + reportActionID?: string; }; -const defaultProps = { - onConfirm: () => {}, - onSendMoney: () => {}, - onSelectParticipant: () => {}, - iouType: CONST.IOU.TYPE.REQUEST, - iouCategory: '', - iouTag: '', - iouIsBillable: false, - onToggleBillable: () => {}, - payeePersonalDetails: null, - canModifyParticipants: false, - isReadOnly: false, - bankAccountRoute: '', - session: { - email: null, +function MoneyRequestConfirmationList({ + 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: selectedParticipantsProp, + payeePersonalDetails: payeePersonalDetailsProp, + iou = { + id: '', + amount: 0, + currency: CONST.CURRENCY.USD, + comment: '', + merchant: '', + category: '', + tag: '', + billable: false, + created: '', + participants: [], + receiptPath: '', }, - policyID: '', - reportID: '', - ...withCurrentUserPersonalDetailsDefaultProps, - receiptPath: '', - receiptFilename: '', - listStyles: [], - policy: {}, - policyCategories: {}, - policyTags: {}, - transactionID: '', - transaction: {}, - mileageRate: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate: 0, currency: 'USD'}, - isDistanceRequest: false, - isScanRequest: false, - shouldShowSmartScanFields: true, - isPolicyExpenseChat: false, - iou: iouDefaultProps, -}; - -function MoneyRequestConfirmationList(props) { + canModifyParticipants: canModifyParticipantsProp = false, + session, + isReadOnly = false, + bankAccountRoute = '', + policyID = '', + reportID = '', + receiptPath = '', + iouComment, + receiptFilename = '', + listStyles, + iouCreated, + iouIsBillable = false, + onToggleBillable, + iouTag = '', + transactionID = '', + hasSmartScanFailed, + reportActionID, +}: MoneyRequestConfirmationListProps) { const theme = useTheme(); const styles = useThemeStyles(); - // Destructure functions from props to pass it as a dependecy to useCallback/useMemo hooks. - // Prop functions pass props itself as a "this" value to the function which means they change every time props change. - const {onSendMoney, onConfirm, onSelectParticipant} = props; const {translate, toLocaleDigit} = useLocalize(); - const transaction = props.transaction; const {canUseP2PDistanceRequests, canUseViolations} = usePermissions(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const isTypeRequest = props.iouType === CONST.IOU.TYPE.REQUEST; - const isSplitBill = props.iouType === CONST.IOU.TYPE.SPLIT; - const isTypeSend = props.iouType === CONST.IOU.TYPE.SEND; + const isTypeRequest = iouType === CONST.IOU.TYPE.REQUEST; + const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; + const isTypeSend = iouType === CONST.IOU.TYPE.SEND; const canEditDistance = isTypeRequest || (canUseP2PDistanceRequests && isSplitBill); - const isSplitWithScan = isSplitBill && props.isScanRequest; + const isSplitWithScan = isSplitBill && isScanRequest; - const {unit, rate, currency} = props.mileageRate; - const distance = lodashGet(transaction, 'routes.route0.distance', 0); - const shouldCalculateDistanceAmount = props.isDistanceRequest && props.iouAmount === 0; - const taxRates = lodashGet(props.policy, 'taxRates', {}); + 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; // A flag for showing the categories field - const shouldShowCategories = props.isPolicyExpenseChat && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories))); + 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 = props.isDistanceRequest || shouldExpandFields || !props.shouldShowSmartScanFields || isTypeSend || props.isEditingSplitBill; + 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 && !props.isDistanceRequest && !isSplitWithScan; + const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !isDistanceRequest && !isSplitWithScan; - const policyTagLists = useMemo(() => PolicyUtils.getTagLists(props.policyTags), [props.policyTags]); + const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); // A flag for showing the tags field - const shouldShowTags = props.isPolicyExpenseChat && (props.iouTag || OptionsListUtils.hasEnabledTags(policyTagLists)); + const shouldShowTags = isPolicyExpenseChat && (!!iouTag || OptionsListUtils.hasEnabledTags(policyTagLists)); // A flag for showing tax fields - tax rate and tax amount - const shouldShowTax = props.isPolicyExpenseChat && lodashGet(props.policy, 'tax.trackingEnabled', props.policy.isTaxTrackingEnabled); + const shouldShowTax = isPolicyExpenseChat && (policy?.tax?.trackingEnabled ?? policy?.isTaxTrackingEnabled); // A flag for showing the billable field - const shouldShowBillable = !lodashGet(props.policy, 'disabledFields.defaultBillable', true); + const shouldShowBillable = !(policy?.disabledFields?.defaultBillable ?? true); const hasRoute = TransactionUtils.hasRoute(transaction); - const isDistanceRequestWithPendingRoute = props.isDistanceRequest && (!hasRoute || !rate); + const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate); const formattedAmount = isDistanceRequestWithPendingRoute ? '' : CurrencyUtils.convertToDisplayString( - shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : props.iouAmount, - props.isDistanceRequest ? currency : props.iouCurrencyCode, + shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate ?? 0) : iouAmount, + isDistanceRequest ? currency : iouCurrencyCode, ); - const formattedTaxAmount = CurrencyUtils.convertToDisplayString(props.transaction.taxAmount, props.iouCurrencyCode); + const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode); - const defaultTaxKey = taxRates.defaultExternalID; - const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || ''; - const taxRateTitle = (props.transaction.taxRate && props.transaction.taxRate.text) || defaultTaxName; + const defaultTaxKey = taxRates?.defaultExternalID; + const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${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 isFocused = useIsFocused(); const [formError, setFormError] = useState(''); @@ -274,67 +285,65 @@ function MoneyRequestConfirmationList(props) { const [didConfirm, setDidConfirm] = useState(false); const [didConfirmSplit, setDidConfirmSplit] = useState(false); - const shouldDisplayFieldError = useMemo(() => { - if (!props.isEditingSplitBill) { + const shouldDisplayFieldError: boolean = useMemo(() => { + if (!isEditingSplitBill) { return false; } - return (props.hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction)); - }, [props.isEditingSplitBill, props.hasSmartScanFailed, transaction, didConfirmSplit]); + return (!!hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction)); + }, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]); - const isMerchantEmpty = !props.iouMerchant || props.iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; - const shouldDisplayMerchantError = props.isPolicyExpenseChat && shouldDisplayFieldError && isMerchantEmpty; + const isMerchantEmpty = !iouMerchant || iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; + const shouldDisplayMerchantError = isPolicyExpenseChat && shouldDisplayFieldError && isMerchantEmpty; useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); return; } - if (shouldDisplayFieldError && props.hasSmartScanFailed) { + if (shouldDisplayFieldError && hasSmartScanFailed) { setFormError('iou.receiptScanningFailed'); return; } // reset the form error whenever the screen gains or loses focus setFormError(''); - }, [isFocused, transaction, shouldDisplayFieldError, props.hasSmartScanFailed, didConfirmSplit]); + }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); useEffect(() => { if (!shouldCalculateDistanceAmount) { return; } - const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate); + const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate ?? 0); IOU.setMoneyRequestAmount(amount); }, [shouldCalculateDistanceAmount, distance, rate, unit]); /** * Returns the participants with amount - * @param {Array} participants - * @returns {Array} */ const getParticipantsWithAmount = useCallback( - (participantsList) => { - const iouAmount = IOUUtils.calculateAmount(participantsList.length, props.iouAmount, props.iouCurrencyCode); + (participantsList: Participant[]): Participant[] => { + const calculatedIouAmount = IOUUtils.calculateAmount(participantsList.length, iouAmount, iouCurrencyCode ?? ''); return OptionsListUtils.getIOUConfirmationOptionsFromParticipants( participantsList, - props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(iouAmount, props.iouCurrencyCode) : '', + iouAmount > 0 ? CurrencyUtils.convertToDisplayString(calculatedIouAmount, iouCurrencyCode) : '', ); }, - [props.iouAmount, props.iouCurrencyCode], + [iouAmount, iouCurrencyCode], ); // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again - if (props.isEditingSplitBill && didConfirm) { + if (isEditingSplitBill && didConfirm) { setDidConfirm(false); } - const splitOrRequestOptions = useMemo(() => { + const splitOrRequestOptions: Array> = useMemo(() => { let text; - if (isSplitBill && props.iouAmount === 0) { + if (isSplitBill && iouAmount === 0) { text = translate('iou.split'); - } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { + } else if ((!!receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { text = translate('iou.request'); - if (props.iouAmount !== 0) { + if (iouAmount !== 0) { text = translate('iou.requestAmount', {amount: formattedAmount}); } } else { @@ -344,34 +353,34 @@ function MoneyRequestConfirmationList(props) { return [ { text: text[0].toUpperCase() + text.slice(1), - value: props.iouType, + value: iouType, }, ]; - }, [isSplitBill, isTypeRequest, props.iouType, props.iouAmount, props.receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]); + }, [isSplitBill, isTypeRequest, iouType, iouAmount, receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]); - const selectedParticipants = useMemo(() => _.filter(props.selectedParticipants, (participant) => participant.selected), [props.selectedParticipants]); - const payeePersonalDetails = useMemo(() => props.payeePersonalDetails || props.currentUserPersonalDetails, [props.payeePersonalDetails, props.currentUserPersonalDetails]); - const canModifyParticipants = !props.isReadOnly && props.canModifyParticipants && props.hasMultipleParticipants; + const selectedParticipants: Participant[] = useMemo(() => selectedParticipantsProp.filter((participant) => participant.selected), [selectedParticipantsProp]); + const payeePersonalDetails = useMemo(() => payeePersonalDetailsProp ?? currentUserPersonalDetails, [payeePersonalDetailsProp, currentUserPersonalDetails]); + const canModifyParticipants = !isReadOnly && canModifyParticipantsProp && hasMultipleParticipants; const shouldDisablePaidBySection = canModifyParticipants; - const optionSelectorSections = useMemo(() => { + const optionSelectorSections: OptionsListUtils.CategorySection[] = useMemo(() => { const sections = []; - const unselectedParticipants = _.filter(props.selectedParticipants, (participant) => !participant.selected); - if (props.hasMultipleParticipants) { + const unselectedParticipants = selectedParticipantsProp.filter((participant) => !participant.selected); + if (hasMultipleParticipants) { const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipants); - let formattedParticipantsList = _.union(formattedSelectedParticipants, unselectedParticipants); + let formattedParticipantsList = [...new Set([...formattedSelectedParticipants, ...unselectedParticipants])]; if (!canModifyParticipants) { - formattedParticipantsList = _.map(formattedParticipantsList, (participant) => ({ + formattedParticipantsList = formattedParticipantsList.map((participant) => ({ ...participant, - isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID), + isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), })); } - const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, props.iouAmount, props.iouCurrencyCode, true); + const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, iouAmount, iouCurrencyCode ?? '', true); const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail( payeePersonalDetails, - props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, props.iouCurrencyCode) : '', + iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode) : '', ); sections.push( @@ -390,9 +399,9 @@ function MoneyRequestConfirmationList(props) { }, ); } else { - const formattedSelectedParticipants = _.map(props.selectedParticipants, (participant) => ({ + const formattedSelectedParticipants = selectedParticipantsProp.map((participant) => ({ ...participant, - isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID), + isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), })); sections.push({ title: translate('common.to'), @@ -403,27 +412,27 @@ function MoneyRequestConfirmationList(props) { } return sections; }, [ - props.selectedParticipants, - props.hasMultipleParticipants, - props.iouAmount, - props.iouCurrencyCode, - getParticipantsWithAmount, selectedParticipants, + hasMultipleParticipants, + iouAmount, + iouCurrencyCode, + getParticipantsWithAmount, + selectedParticipantsProp, payeePersonalDetails, translate, shouldDisablePaidBySection, canModifyParticipants, ]); - const selectedOptions = useMemo(() => { - if (!props.hasMultipleParticipants) { + const selectedOptions: Array = useMemo(() => { + if (!hasMultipleParticipants) { return []; } return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails)]; - }, [selectedParticipants, props.hasMultipleParticipants, payeePersonalDetails]); + }, [selectedParticipants, hasMultipleParticipants, payeePersonalDetails]); useEffect(() => { - if (!props.isDistanceRequest) { + if (!isDistanceRequest) { return; } @@ -432,31 +441,27 @@ function MoneyRequestConfirmationList(props) { 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(props.transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); + IOU.setMoneyRequestPendingFields(transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); - const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(hasRoute, distance, unit, rate, currency, translate, toLocaleDigit); - IOU.setMoneyRequestMerchant(props.transactionID, distanceMerchant, false); - }, [isDistanceRequestWithPendingRoute, hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, props.isDistanceRequest, props.transactionID]); + 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]); - /** - * @param {Object} option - */ const selectParticipant = useCallback( - (option) => { + (option: Participant) => { // Return early if selected option is currently logged in user. - if (option.accountID === props.session.accountID) { + if (option.accountID === session?.accountID) { return; } - onSelectParticipant(option); + onSelectParticipant?.(option); }, - [props.session.accountID, onSelectParticipant], + [session?.accountID, onSelectParticipant], ); /** * Navigate to report details or profile of selected user - * @param {Object} option */ - const navigateToReportOrUserDetail = (option) => { + const navigateToReportOrUserDetail = (option: ReportUtils.OptionData) => { if (option.accountID) { const activeRoute = Navigation.getActiveRouteWithoutParams(); @@ -466,19 +471,16 @@ function MoneyRequestConfirmationList(props) { } }; - /** - * @param {String} paymentMethod - */ const confirm = useCallback( - (paymentMethod) => { - if (_.isEmpty(selectedParticipants)) { + (paymentMethod: IouType | PaymentMethodType | undefined) => { + if (!selectedParticipants.length) { return; } - if (props.iouCategory && props.iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) { + if (iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) { setFormError('iou.error.invalidCategoryLength'); return; } - if (props.iouType === CONST.IOU.TYPE.SEND) { + if (iouType === CONST.IOU.TYPE.SEND) { if (!paymentMethod) { return; } @@ -486,45 +488,45 @@ function MoneyRequestConfirmationList(props) { setDidConfirm(true); Log.info(`[IOU] Sending money via: ${paymentMethod}`); - onSendMoney(paymentMethod); + onSendMoney?.(paymentMethod); } else { // validate the amount for distance requests - const decimals = CurrencyUtils.getCurrencyDecimals(props.iouCurrencyCode); - if (props.isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount), decimals)) { + const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode); + if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) { setFormError('common.error.invalidAmount'); return; } - if (props.isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction)) { + if (isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction)) { setDidConfirmSplit(true); return; } setDidConfirm(true); - onConfirm(selectedParticipants); + onConfirm?.(selectedParticipants); } }, [ selectedParticipants, onSendMoney, onConfirm, - props.isEditingSplitBill, - props.iouType, - props.isDistanceRequest, - props.iouCategory, + isEditingSplitBill, + iouType, + isDistanceRequest, + iouCategory, isDistanceRequestWithPendingRoute, - props.iouCurrencyCode, - props.iouAmount, + iouCurrencyCode, + iouAmount, transaction, ], ); const footerContent = useMemo(() => { - if (props.isReadOnly) { + if (isReadOnly) { return; } - const shouldShowSettlementButton = props.iouType === CONST.IOU.TYPE.SEND; + const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.SEND; const shouldDisableButton = selectedParticipants.length === 0 || shouldDisplayMerchantError; const button = shouldShowSettlementButton ? ( @@ -533,10 +535,10 @@ function MoneyRequestConfirmationList(props) { isDisabled={shouldDisableButton} onPress={confirm} enablePaymentsRoute={ROUTES.IOU_SEND_ENABLE_PAYMENTS} - addBankAccountRoute={props.bankAccountRoute} + addBankAccountRoute={bankAccountRoute} addDebitCardRoute={ROUTES.IOU_SEND_ADD_DEBIT_CARD} - currency={props.iouCurrencyCode} - policyID={props.policyID} + currency={iouCurrencyCode} + policyID={policyID} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} kycWallAnchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, @@ -554,16 +556,16 @@ function MoneyRequestConfirmationList(props) { success pressOnEnter isDisabled={shouldDisableButton} + // eslint-disable-next-line @typescript-eslint/naming-convention onPress={(_event, value) => confirm(value)} options={splitOrRequestOptions} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} enterKeyEventListenerPriority={1} /> ); - return ( <> - {!_.isEmpty(formError) && ( + {!!formError.length && ( ); }, [ - props.isReadOnly, - props.iouType, - props.bankAccountRoute, - props.iouCurrencyCode, - props.policyID, + isReadOnly, + iouType, + bankAccountRoute, + iouCurrencyCode, + policyID, selectedParticipants.length, shouldDisplayMerchantError, confirm, @@ -589,8 +591,9 @@ function MoneyRequestConfirmationList(props) { ]); const {image: receiptImage, thumbnail: receiptThumbnail} = - props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, props.receiptPath, props.receiptFilename) : {}; + receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : ({} as ReceiptUtils.ThumbnailAndImageURI); 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) - {props.isDistanceRequest && ( + {isDistanceRequest && ( - + )} + {receiptImage || receiptThumbnail ? ( ) : ( // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") - PolicyUtils.isPaidGroupPolicy(props.policy) && - !props.isDistanceRequest && - props.iouType === CONST.IOU.TYPE.REQUEST && ( + PolicyUtils.isPaidGroupPolicy(policy) && + !isDistanceRequest && + iouType === CONST.IOU.TYPE.REQUEST && ( Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( CONST.IOU.ACTION.CREATE, - props.iouType, - transaction.transactionID, - props.reportID, + iouType, + transaction?.transactionID ?? '', + reportID, Navigation.getActiveRouteWithoutParams(), ), ) @@ -643,49 +648,43 @@ function MoneyRequestConfirmationList(props) { /> ) )} - {props.shouldShowSmartScanFields && ( + {shouldShowSmartScanFields && ( { - if (props.isDistanceRequest) { + if (isDistanceRequest) { return; } - if (props.isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.AMOUNT)); + if (isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID ?? '', CONST.EDIT_REQUEST_FIELD.AMOUNT)); return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(props.iouType, props.reportID)); + Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(iouType, reportID)); }} style={[styles.moneyRequestMenuItem, styles.mt2]} titleStyle={styles.moneyRequestConfirmationAmount} disabled={didConfirm} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + 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, - props.iouType, - transaction.transactionID, - props.reportID, - Navigation.getActiveRouteWithoutParams(), - ), + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), ); }} - style={[styles.moneyRequestMenuItem]} + style={styles.moneyRequestMenuItem} titleStyle={styles.flex1} disabled={didConfirm} - interactive={!props.isReadOnly} + interactive={!isReadOnly} numberOfLinesTitle={2} /> {!shouldShowAllFields && ( @@ -698,162 +697,162 @@ function MoneyRequestConfirmationList(props) { <> {shouldShowDate && ( { Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_DATE.getRoute( CONST.IOU.ACTION.EDIT, - props.iouType, - transaction.transactionID, - props.reportID, + iouType, + transaction?.transactionID ?? '', + reportID, Navigation.getActiveRouteWithoutParams(), ), ); }} disabled={didConfirm} - interactive={!props.isReadOnly} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + interactive={!isReadOnly} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} /> )} - {props.isDistanceRequest && ( + {isDistanceRequest && ( Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(props.iouType, props.reportID))} - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(iouType, reportID))} disabled={didConfirm || !canEditDistance} - interactive={!props.isReadOnly} + interactive={!isReadOnly} /> )} {shouldShowMerchant && ( { Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute( CONST.IOU.ACTION.EDIT, - props.iouType, - transaction.transactionID, - props.reportID, + iouType, + transaction?.transactionID ?? '', + reportID, Navigation.getActiveRouteWithoutParams(), ), ); }} disabled={didConfirm} - interactive={!props.isReadOnly} - brickRoadIndicator={shouldDisplayMerchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + 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, - props.iouType, - props.transaction.transactionID, - props.reportID, + iouType, + transaction?.transactionID ?? '', + reportID, Navigation.getActiveRouteWithoutParams(), ), ); }} - style={[styles.moneyRequestMenuItem]} + style={styles.moneyRequestMenuItem} titleStyle={styles.flex1} disabled={didConfirm} - interactive={!props.isReadOnly} - rightLabel={canUseViolations && Boolean(props.policy.requiresCategory) ? translate('common.required') : ''} + interactive={!isReadOnly} + rightLabel={canUseViolations && !!policy?.requiresCategory ? translate('common.required') : ''} /> )} {shouldShowTags && - _.map(policyTagLists, ({name}, index) => ( + policyTagLists.map(({name}, index) => ( { - if (props.isEditingSplitBill) { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute( - CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.SPLIT, - index, - props.transaction.transactionID, - props.reportID, - Navigation.getActiveRouteWithoutParams(), - props.reportActionID, - ), - ); + if (!isEditingSplitBill) { return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID)); + + 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]} + style={styles.moneyRequestMenuItem} disabled={didConfirm} - interactive={!props.isReadOnly} - rightLabel={canUseViolations && Boolean(props.policy.requiresTag) ? translate('common.required') : ''} + interactive={!isReadOnly} + rightLabel={canUseViolations && !!policy?.requiresTag ? translate('common.required') : ''} /> ))} {shouldShowTax && ( Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()), + ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), ) } disabled={didConfirm} - interactive={!props.isReadOnly} + interactive={!isReadOnly} /> )} {shouldShowTax && ( Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()), + ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), ) } disabled={didConfirm} - interactive={!props.isReadOnly} + interactive={!isReadOnly} /> )} {shouldShowBillable && ( - {translate('common.billable')} + {translate('common.billable')} onToggleBillable?.(isOn)} /> )} @@ -863,34 +862,26 @@ function MoneyRequestConfirmationList(props) { ); } -MoneyRequestConfirmationList.propTypes = propTypes; -MoneyRequestConfirmationList.defaultProps = defaultProps; MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList'; -export default compose( - withCurrentUserPersonalDetails, - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - policyCategories: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, - }, - policyTags: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, - }, - mileageRate: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - selector: DistanceRequestUtils.getDefaultMileageRate, - }, - splitTransactionDraft: { - key: ({transactionID}) => `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - }, - policy: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - }, - iou: { - key: ONYXKEYS.IOU, - }, - }), -)(MoneyRequestConfirmationList); +export default withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + policyCategories: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + }, + policyTags: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + }, + mileageRate: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + selector: DistanceRequestUtils.getDefaultMileageRate, + }, + policy: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + }, + iou: { + key: ONYXKEYS.IOU, + }, +})(MoneyRequestConfirmationList); diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index b5c84354e466..a9304b9c3138 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -163,7 +163,7 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, { // We don't want to activate pinch gesture when we are swiping in the pager if (!shouldDisableTransformationGestures.value) { diff --git a/src/components/MultiGestureCanvas/useTapGestures.ts b/src/components/MultiGestureCanvas/useTapGestures.ts index a28333725d6e..f550e93d6be2 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.ts +++ b/src/components/MultiGestureCanvas/useTapGestures.ts @@ -121,7 +121,6 @@ const useTapGestures = ({ const doubleTapGesture = Gesture.Tap() // The first argument is not used, but must be defined - // eslint-disable-next-line @typescript-eslint/naming-convention .onTouchesDown((_evt, state) => { if (!shouldDisableTransformationGestures.value) { return; @@ -156,7 +155,6 @@ const useTapGestures = ({ .onBegin(() => { stopAnimation(); }) - // eslint-disable-next-line @typescript-eslint/naming-convention .onFinalize((_evt, success) => { if (!success || onTap === undefined) { return; diff --git a/src/components/OptionsList/BaseOptionsList.tsx b/src/components/OptionsList/BaseOptionsList.tsx index 575df128894a..3844080c6f5d 100644 --- a/src/components/OptionsList/BaseOptionsList.tsx +++ b/src/components/OptionsList/BaseOptionsList.tsx @@ -133,7 +133,6 @@ function BaseOptionsList( * * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}] */ - // eslint-disable-next-line @typescript-eslint/naming-convention const getItemLayout = (_data: OptionsListData[] | null, flatDataArrayIndex: number) => { if (!flattenedData.current[flatDataArrayIndex]) { flattenedData.current = buildFlatSectionArray(); diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index a391ff061baa..83da817da858 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -18,7 +18,7 @@ import Text from './Text'; type PopoverMenuItem = { /** An icon element displayed on the left side */ - icon: IconAsset; + icon?: IconAsset; /** Text label */ text: string; diff --git a/src/components/RadioButtonWithLabel.tsx b/src/components/RadioButtonWithLabel.tsx index cfcd6acba41f..9b93d7900772 100644 --- a/src/components/RadioButtonWithLabel.tsx +++ b/src/components/RadioButtonWithLabel.tsx @@ -72,3 +72,5 @@ function RadioButtonWithLabel({LabelComponent, style, label = '', hasError = fal RadioButtonWithLabel.displayName = 'RadioButtonWithLabel'; export default RadioButtonWithLabel; + +export type {RadioButtonWithLabelProps}; diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 8577c9fa5f97..311ed75be3e0 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -24,7 +24,6 @@ import ControlSelection from '@libs/ControlSelection'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as IOUUtils from '@libs/IOUUtils'; -import * as Localize from '@libs/Localize'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -152,12 +151,25 @@ function MoneyRequestPreviewContent({ } let message = translate('iou.cash'); - if (hasViolations && transaction) { + if (shouldShowRBR && transaction) { const violations = TransactionUtils.getTransactionViolations(transaction.transactionID, transactionViolations); if (violations?.[0]) { const violationMessage = ViolationsUtils.getViolationTranslation(violations[0], translate); - const isTooLong = violations.filter((v) => v.type === 'violation').length > 1 || violationMessage.length > 15; - message += ` • ${isTooLong ? translate('violations.reviewRequired') : violationMessage}`; + const violationsCount = violations.filter((v) => v.type === 'violation').length; + const isTooLong = violationsCount > 1 || violationMessage.length > 15; + const hasViolationsAndFieldErrors = violationsCount > 0 && hasFieldErrors; + + return `${message} • ${isTooLong || hasViolationsAndFieldErrors ? translate('violations.reviewRequired') : violationMessage}`; + } + + const isMerchantMissing = TransactionUtils.isMerchantMissing(transaction); + const isAmountMissing = TransactionUtils.isAmountMissing(transaction); + if (isAmountMissing && isMerchantMissing) { + message += ` • ${translate('violations.reviewRequired')}`; + } else if (isAmountMissing) { + message += ` • ${translate('iou.missingAmount')}`; + } else { + message += ` • ${translate('iou.missingMerchant')}`; } } else if (ReportUtils.isPaidGroupPolicyExpenseReport(iouReport) && ReportUtils.isReportApproved(iouReport) && !ReportUtils.isSettled(iouReport?.reportID)) { message += ` • ${translate('iou.approved')}`; @@ -180,10 +192,6 @@ function MoneyRequestPreviewContent({ return translate('iou.routePending'); } - if (!isSettled && TransactionUtils.hasMissingSmartscanFields(transaction)) { - return Localize.translateLocal('iou.receiptMissingDetails'); - } - return CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency); }; diff --git a/src/components/ReportActionItem/ReportActionItemImages.tsx b/src/components/ReportActionItem/ReportActionItemImages.tsx index 74ecc1a2adbd..ffc12957dcb4 100644 --- a/src/components/ReportActionItem/ReportActionItemImages.tsx +++ b/src/components/ReportActionItem/ReportActionItemImages.tsx @@ -109,3 +109,4 @@ function ReportActionItemImages({images, size, total, isHovered = false}: Report ReportActionItemImages.displayName = 'ReportActionItemImages'; export default ReportActionItemImages; +export type {ReportActionItemImagesProps}; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 743bfd8fff88..f1aa1751dd84 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -1,8 +1,8 @@ import React, {useMemo} from 'react'; -import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; +import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -29,7 +29,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy, Report, ReportAction, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, Transaction, TransactionViolations, UserWallet} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import ReportActionItemImages from './ReportActionItemImages'; @@ -48,6 +48,9 @@ type ReportPreviewOnyxProps = { /** All of the transaction violations */ transactionViolations: OnyxCollection; + + /** The user's wallet account */ + userWallet: OnyxEntry; }; type ReportPreviewProps = ReportPreviewOnyxProps & { @@ -94,6 +97,7 @@ function ReportPreview({ isHovered = false, isWhisper = false, checkIfContextMenuActive = () => {}, + userWallet, }: ReportPreviewProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -206,6 +210,9 @@ function ReportPreview({ const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; + const shouldPromptUserToAddBankAccount = ReportUtils.hasMissingPaymentMethod(userWallet, iouReportID); + const shouldShowRBR = !iouSettled && hasErrors; + /* Show subtitle if at least one of the money requests is not being smart scanned, and either: - There is more than one money request – in this case, the "X requests, Y scanning" subtitle is shown; @@ -251,12 +258,19 @@ function ReportPreview({ {getPreviewMessage()} - {!iouSettled && hasErrors && ( + {shouldShowRBR && ( )} + + {!shouldShowRBR && shouldPromptUserToAddBankAccount && ( + + )} @@ -338,4 +352,7 @@ export default withOnyx({ transactionViolations: { key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, }, + userWallet: { + key: ONYXKEYS.USER_WALLET, + }, })(ReportPreview); diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 306846ad7d99..827eec8088a6 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -148,7 +148,6 @@ function ScreenWrapper( const panResponder = useRef( PanResponder.create({ - // eslint-disable-next-line @typescript-eslint/naming-convention onStartShouldSetPanResponderCapture: (_e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS, onPanResponderRelease: toggleTestToolsModal, }), @@ -156,7 +155,6 @@ function ScreenWrapper( const keyboardDissmissPanResponder = useRef( PanResponder.create({ - // eslint-disable-next-line @typescript-eslint/naming-convention onMoveShouldSetPanResponderCapture: (_e, gestureState) => { const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy); const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && isKeyboardShown && Browser.isMobile(); diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 8c352a4d084a..db6204c8c1ef 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -1,4 +1,5 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import isEmpty from 'lodash/isEmpty'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, SectionListRenderItemInfo} from 'react-native'; @@ -10,6 +11,7 @@ import FixedFooter from '@components/FixedFooter'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import SectionList from '@components/SectionList'; +import ShowMoreButton from '@components/ShowMoreButton'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import useActiveElementRole from '@hooks/useActiveElementRole'; @@ -81,6 +83,9 @@ function BaseSelectionList( const [isInitialSectionListRender, setIsInitialSectionListRender] = useState(true); const [itemsToHighlight, setItemsToHighlight] = useState | null>(null); const itemFocusTimeoutRef = useRef(null); + const [currentPage, setCurrentPage] = useState(1); + + const incrementPage = () => setCurrentPage((prev) => prev + 1); /** * Iterates through the sections and items inside each section, and builds 3 arrays along the way: @@ -156,6 +161,33 @@ function BaseSelectionList( // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member const [focusedIndex, setFocusedIndex] = useState(() => flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey)); + const [slicedSections, ShowMoreButtonInstance] = useMemo( + () => [ + sections.map((section) => { + if (isEmpty(section.data)) { + return section; + } + + return { + ...section, + data: section.data.slice(0, CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * currentPage), + }; + }), + flattenedSections.allOptions.length > CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * currentPage ? ( + + ) : null, + ], + // we don't need to add styles here as they change + // we don't need to add flattendedSections here as they will change along with sections + // eslint-disable-next-line react-hooks/exhaustive-deps + [sections, currentPage], + ); + // Disable `Enter` shortcut if the active element is a button or checkbox const disableEnterShortcut = activeElementRole && [CONST.ROLE.BUTTON, CONST.ROLE.CHECKBOX].includes(activeElementRole as ButtonOrCheckBoxRoles); @@ -364,6 +396,8 @@ function BaseSelectionList( } // Remove the focus if the search input is empty else focus on the first non disabled item const newSelectedIndex = textInputValue === '' ? -1 : 0; + // reseting the currrent page to 1 when the user types something + setCurrentPage(1); updateAndScrollToFocusedIndex(newSelectedIndex); }, [canSelectMultiple, flattenedSections.allOptions.length, prevTextInputValue, textInputValue, updateAndScrollToFocusedIndex]); @@ -492,7 +526,7 @@ function BaseSelectionList( {!headerMessage && !canSelectMultiple && customListHeader} ( testID="selection-list" onLayout={onSectionListLayout} style={(!maxToRenderPerBatch || isInitialSectionListRender) && styles.opacity0} + ListFooterComponent={ShowMoreButtonInstance} /> {children} diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx index 882ca56076bd..2a3a8dd04a79 100644 --- a/src/components/SelectionList/UserListItem.tsx +++ b/src/components/SelectionList/UserListItem.tsx @@ -1,3 +1,4 @@ +import Str from 'expensify-common/lib/str'; import React from 'react'; import {View} from 'react-native'; import MultipleAvatars from '@components/MultipleAvatars'; @@ -81,7 +82,7 @@ function UserListItem({ )} diff --git a/src/components/SwipeableView/index.native.tsx b/src/components/SwipeableView/index.native.tsx index 67c6695c1a7f..e5b6d371e606 100644 --- a/src/components/SwipeableView/index.native.tsx +++ b/src/components/SwipeableView/index.native.tsx @@ -9,7 +9,6 @@ function SwipeableView({children, onSwipeDown}: SwipeableViewProps) { const panResponder = useRef( PanResponder.create({ // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance & swipe direction is downwards - // eslint-disable-next-line @typescript-eslint/naming-convention onMoveShouldSetPanResponderCapture: (_event, gestureState) => { if (gestureState.dy - oldYRef.current > 0 && gestureState.dy > minimumPixelDistance) { return true; diff --git a/src/components/WorkspaceSwitcherButton.tsx b/src/components/WorkspaceSwitcherButton.tsx index 963782bb50d5..a94f54682c85 100644 --- a/src/components/WorkspaceSwitcherButton.tsx +++ b/src/components/WorkspaceSwitcherButton.tsx @@ -1,13 +1,11 @@ import React, {useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import * as Expensicons from './Icon/Expensicons'; @@ -19,14 +17,14 @@ type WorkspaceSwitcherButtonOnyxProps = { policy: OnyxEntry; }; -type WorkspaceSwitcherButtonProps = {activeWorkspaceID?: string} & WorkspaceSwitcherButtonOnyxProps; +type WorkspaceSwitcherButtonProps = WorkspaceSwitcherButtonOnyxProps; -function WorkspaceSwitcherButton({activeWorkspaceID, policy}: WorkspaceSwitcherButtonProps) { +function WorkspaceSwitcherButton({policy}: WorkspaceSwitcherButtonProps) { const {translate} = useLocalize(); const theme = useTheme(); const {source, name, type} = useMemo(() => { - if (!activeWorkspaceID) { + if (!policy) { return {source: Expensicons.ExpensifyAppIcon, name: CONST.WORKSPACE_SWITCHER.NAME, type: CONST.ICON_TYPE_AVATAR}; } @@ -36,7 +34,7 @@ function WorkspaceSwitcherButton({activeWorkspaceID, policy}: WorkspaceSwitcherB name: policy?.name ?? '', type: CONST.ICON_TYPE_WORKSPACE, }; - }, [policy, activeWorkspaceID]); + }, [policy]); return ( @@ -71,8 +69,4 @@ function WorkspaceSwitcherButton({activeWorkspaceID, policy}: WorkspaceSwitcherB WorkspaceSwitcherButton.displayName = 'WorkspaceSwitcherButton'; -export default withOnyx({ - policy: { - key: ({activeWorkspaceID}) => `${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID}`, - }, -})(WorkspaceSwitcherButton); +export default WorkspaceSwitcherButton; diff --git a/src/hooks/useAppState/index.native.ts b/src/hooks/useAppState/index.native.ts new file mode 100644 index 000000000000..39c1dff65e7c --- /dev/null +++ b/src/hooks/useAppState/index.native.ts @@ -0,0 +1,28 @@ +import React from 'react'; +import type {AppStateStatus} from 'react-native'; +import {AppState} from 'react-native'; +import type AppStateType from './types'; + +function useAppState() { + const [appState, setAppState] = React.useState({ + isForeground: AppState.currentState === 'active', + isInactive: AppState.currentState === 'inactive', + isBackground: AppState.currentState === 'background', + }); + + React.useEffect(() => { + function handleAppStateChange(nextAppState: AppStateStatus) { + setAppState({ + isForeground: nextAppState === 'active', + isInactive: nextAppState === 'inactive', + isBackground: nextAppState === 'background', + }); + } + const subscription = AppState.addEventListener('change', handleAppStateChange); + return () => subscription.remove(); + }, []); + + return appState; +} + +export default useAppState; diff --git a/src/hooks/useAppState/index.ts b/src/hooks/useAppState/index.ts new file mode 100644 index 000000000000..74d68535d1d1 --- /dev/null +++ b/src/hooks/useAppState/index.ts @@ -0,0 +1,8 @@ +import type AppStateType from './types'; + +function useAppState(): AppStateType { + // Since there's no AppState in web, we'll always return isForeground as true + return {isForeground: true, isInactive: false, isBackground: false}; +} + +export default useAppState; diff --git a/src/hooks/useAppState/types.ts b/src/hooks/useAppState/types.ts new file mode 100644 index 000000000000..9093b89fc4dc --- /dev/null +++ b/src/hooks/useAppState/types.ts @@ -0,0 +1,7 @@ +type AppStateType = { + isForeground: boolean; + isInactive: boolean; + isBackground: boolean; +}; + +export default AppStateType; diff --git a/src/languages/en.ts b/src/languages/en.ts index 5023708c53b7..7e442eee2236 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -605,6 +605,8 @@ export default { routePending: 'Route pending...', receiptScanning: 'Scan in progress…', receiptMissingDetails: 'Receipt missing details', + missingAmount: 'Missing amount', + missingMerchant: 'Missing merchant', receiptStatusTitle: 'Scanning…', receiptStatusText: "Only you can see this receipt when it's scanning. Check back later or enter the details now.", receiptScanningFailed: 'Receipt scanning failed. Enter the details manually.', @@ -1070,6 +1072,11 @@ export default { }, }, }, + workflowsPayerPage: { + title: 'Authorized payer', + genericErrorMessage: 'The authorized payer could not be changed. Please try again.', + admins: 'Admins', + }, reportFraudPage: { title: 'Report virtual card fraud', description: 'If your virtual card details have been stolen or compromised, we’ll permanently deactivate your existing card and provide you with a new virtual card and number.', @@ -1827,11 +1834,14 @@ export default { requiresTag: 'Members must tag all spend', customTagName: 'Custom tag name', enableTag: 'Enable tag', + addTag: 'Add tag', subtitle: 'Tags add more detailed ways to classify costs.', emptyTags: { title: "You haven't created any tags", subtitle: 'Add a tag to track projects, locations, departments, and more.', }, + tagRequiredError: 'Tag name is required.', + existingTagError: 'A tag with this name already exists.', genericFailureMessage: 'An error occurred while updating the tag, please try again.', }, emptyWorkspace: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 0a5ff9d539d9..267581f043ee 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -598,6 +598,8 @@ export default { routePending: 'Ruta pendiente...', receiptScanning: 'Escaneo en curso…', receiptMissingDetails: 'Recibo con campos vacíos', + missingAmount: 'Falta importe', + missingMerchant: 'Falta comerciante', receiptStatusTitle: 'Escaneando…', receiptStatusText: 'Solo tú puedes ver este recibo cuando se está escaneando. Vuelve más tarde o introduce los detalles ahora.', receiptScanningFailed: 'El escaneo de recibo ha fallado. Introduce los detalles manualmente.', @@ -1066,6 +1068,11 @@ export default { }, }, }, + workflowsPayerPage: { + title: 'Pagador autorizado', + genericErrorMessage: 'El pagador autorizado no se pudo cambiar. Por favor, inténtalo mas tarde.', + admins: 'Administradores', + }, reportFraudPage: { title: 'Reportar fraude con la tarjeta virtual', description: @@ -1851,11 +1858,14 @@ export default { requiresTag: 'Los miembros deben etiquetar todos los gastos', customTagName: 'Nombre de etiqueta personalizada', enableTag: 'Habilitar etiqueta', + addTag: 'Añadir etiqueta', subtitle: 'Las etiquetas añaden formas más detalladas de clasificar los costos.', emptyTags: { title: 'No has creado ninguna etiqueta', subtitle: 'Añade una etiqueta para realizar el seguimiento de proyectos, ubicaciones, departamentos y otros.', }, + tagRequiredError: 'Lo nombre de la etiqueta es obligatorio.', + existingTagError: 'Ya existe una etiqueta con este nombre.', genericFailureMessage: 'Se produjo un error al actualizar la etiqueta, inténtelo nuevamente.', }, emptyWorkspace: { diff --git a/src/languages/types.ts b/src/languages/types.ts index 6b4298b70d3c..2d04bdc156c9 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -111,7 +111,7 @@ type RequestAmountParams = {amount: string}; type RequestedAmountMessageParams = {formattedAmount: string; comment?: string}; -type SplitAmountParams = {amount: number}; +type SplitAmountParams = {amount: string}; type DidSplitAmountMessageParams = {formattedAmount: string; comment: string}; diff --git a/src/libs/API/parameters/CreatePolicyTagsParams.ts b/src/libs/API/parameters/CreatePolicyTagsParams.ts new file mode 100644 index 000000000000..6fd16d9ca87b --- /dev/null +++ b/src/libs/API/parameters/CreatePolicyTagsParams.ts @@ -0,0 +1,10 @@ +type CreatePolicyTagsParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array<{name: string;}> + */ + tags: string; +}; + +export default CreatePolicyTagsParams; diff --git a/src/libs/API/parameters/OpenPolicyWorkflowsPageParams.ts b/src/libs/API/parameters/OpenPolicyWorkflowsPageParams.ts new file mode 100644 index 000000000000..eea0788b3927 --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyWorkflowsPageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyWorkflowsPageParams = { + policyID: string; +}; + +export default OpenPolicyWorkflowsPageParams; diff --git a/src/libs/API/parameters/ReconnectAppParams.ts b/src/libs/API/parameters/ReconnectAppParams.ts index 8c5b7d6c0da9..d8c1da4f0887 100644 --- a/src/libs/API/parameters/ReconnectAppParams.ts +++ b/src/libs/API/parameters/ReconnectAppParams.ts @@ -2,6 +2,7 @@ type ReconnectAppParams = { mostRecentReportActionLastModified?: string; updateIDFrom?: number; policyIDList: string[]; + idempotencyKey?: string; }; export default ReconnectAppParams; diff --git a/src/libs/API/parameters/SetWorkspacePayerParams.ts b/src/libs/API/parameters/SetWorkspacePayerParams.ts new file mode 100644 index 000000000000..d1c976c31dd3 --- /dev/null +++ b/src/libs/API/parameters/SetWorkspacePayerParams.ts @@ -0,0 +1,6 @@ +type SetWorkspacePayerParams = { + policyID: string; + reimburserEmail: string; +}; + +export default SetWorkspacePayerParams; diff --git a/src/libs/API/parameters/SetWorkspaceReimbursementParams.ts b/src/libs/API/parameters/SetWorkspaceReimbursementParams.ts new file mode 100644 index 000000000000..f96f6385f541 --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceReimbursementParams.ts @@ -0,0 +1,9 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type SetWorkspaceReimbursementParams = { + policyID: string; + reimbursementChoice: ValueOf; +}; + +export default SetWorkspaceReimbursementParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 7e0e9b6e4a96..687f32f5b6de 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -156,6 +156,8 @@ export type {default as SetWorkspaceAutoReportingParams} from './SetWorkspaceAut export type {default as SetWorkspaceAutoReportingFrequencyParams} from './SetWorkspaceAutoReportingFrequencyParams'; export type {default as SetWorkspaceAutoReportingMonthlyOffsetParams} from './SetWorkspaceAutoReportingMonthlyOffsetParams'; export type {default as SetWorkspaceApprovalModeParams} from './SetWorkspaceApprovalModeParams'; +export type {default as SetWorkspacePayerParams} from './SetWorkspacePayerParams'; +export type {default as SetWorkspaceReimbursementParams} from './SetWorkspaceReimbursementParams'; export type {default as SetPolicyRequiresTag} from './SetPolicyRequiresTag'; export type {default as RenamePolicyTaglist} from './RenamePolicyTaglist'; export type {default as SwitchToOldDotParams} from './SwitchToOldDotParams'; @@ -169,4 +171,6 @@ export type {default as EnablePolicyReportFieldsParams} from './EnablePolicyRepo export type {default as AcceptJoinRequestParams} from './AcceptJoinRequest'; export type {default as DeclineJoinRequestParams} from './DeclineJoinRequest'; export type {default as JoinPolicyInviteLinkParams} from './JoinPolicyInviteLink'; +export type {default as OpenPolicyWorkflowsPageParams} from './OpenPolicyWorkflowsPageParams'; export type {default as OpenPolicyDistanceRatesPageParams} from './OpenPolicyDistanceRatesPageParams'; +export type {default as CreatePolicyTagsParams} from './CreatePolicyTagsParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index bf215e09e37d..17c38b252e0b 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -12,6 +12,8 @@ const WRITE_COMMANDS = { SET_WORKSPACE_AUTO_REPORTING_FREQUENCY: 'SetWorkspaceAutoReportingFrequency', SET_WORKSPACE_AUTO_REPORTING_MONTHLY_OFFSET: 'UpdatePolicy', SET_WORKSPACE_APPROVAL_MODE: 'SetWorkspaceApprovalMode', + SET_WORKSPACE_PAYER: 'SetWorkspacePayer', + SET_WORKSPACE_REIMBURSEMENT: 'SetWorkspaceReimbursement', DISMISS_REFERRAL_BANNER: 'DismissReferralBanner', UPDATE_PREFERRED_LOCALE: 'UpdatePreferredLocale', OPEN_APP: 'OpenApp', @@ -116,6 +118,7 @@ const WRITE_COMMANDS = { CREATE_WORKSPACE_FROM_IOU_PAYMENT: 'CreateWorkspaceFromIOUPayment', SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled', CREATE_WORKSPACE_CATEGORIES: 'CreateWorkspaceCategories', + CREATE_POLICY_TAG: 'CreatePolicyTag', SET_WORKSPACE_REQUIRES_CATEGORY: 'SetWorkspaceRequiresCategory', SET_POLICY_REQUIRES_TAG: 'SetPolicyRequiresTag', RENAME_POLICY_TAG_LIST: 'RenamePolicyTaglist', @@ -281,6 +284,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; [WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag; [WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglist; + [WRITE_COMMANDS.CREATE_POLICY_TAG]: Parameters.CreatePolicyTagsParams; [WRITE_COMMANDS.CREATE_TASK]: Parameters.CreateTaskParams; [WRITE_COMMANDS.CANCEL_TASK]: Parameters.CancelTaskParams; [WRITE_COMMANDS.EDIT_TASK_ASSIGNEE]: Parameters.EditTaskAssigneeParams; @@ -325,6 +329,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING_FREQUENCY]: Parameters.SetWorkspaceAutoReportingFrequencyParams; [WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING_MONTHLY_OFFSET]: Parameters.SetWorkspaceAutoReportingMonthlyOffsetParams; [WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE]: Parameters.SetWorkspaceApprovalModeParams; + [WRITE_COMMANDS.SET_WORKSPACE_PAYER]: Parameters.SetWorkspacePayerParams; + [WRITE_COMMANDS.SET_WORKSPACE_REIMBURSEMENT]: Parameters.SetWorkspaceReimbursementParams; [WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams; [WRITE_COMMANDS.ENABLE_POLICY_CATEGORIES]: Parameters.EnablePolicyCategoriesParams; [WRITE_COMMANDS.ENABLE_POLICY_CONNECTIONS]: Parameters.EnablePolicyConnectionsParams; @@ -370,6 +376,7 @@ const READ_COMMANDS = { OPEN_POLICY_TAGS_PAGE: 'OpenPolicyTagsPage', OPEN_WORKSPACE_INVITE_PAGE: 'OpenWorkspaceInvitePage', OPEN_DRAFT_WORKSPACE_REQUEST: 'OpenDraftWorkspaceRequest', + OPEN_POLICY_WORKFLOWS_PAGE: 'OpenPolicyWorkflowsPage', OPEN_POLICY_DISTANCE_RATES_PAGE: 'OpenPolicyDistanceRatesPage', } as const; @@ -407,6 +414,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_TAGS_PAGE]: Parameters.OpenPolicyTagsPageParams; [READ_COMMANDS.OPEN_WORKSPACE_INVITE_PAGE]: Parameters.OpenWorkspaceInvitePageParams; [READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST]: Parameters.OpenDraftWorkspaceRequestParams; + [READ_COMMANDS.OPEN_POLICY_WORKFLOWS_PAGE]: Parameters.OpenPolicyWorkflowsPageParams; [READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE]: Parameters.OpenPolicyDistanceRatesPageParams; }; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index aef615018b4c..985499078c9a 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -146,3 +146,5 @@ function getDistanceRequestAmount(distance: number, unit: Unit, rate: number): n } export default {getDefaultMileageRate, getDistanceMerchant, getDistanceRequestAmount}; + +export type {DefaultMileageRate}; diff --git a/src/libs/E2E/client.ts b/src/libs/E2E/client.ts index f76bdf2ed9a5..4c0e572cc9b2 100644 --- a/src/libs/E2E/client.ts +++ b/src/libs/E2E/client.ts @@ -1,23 +1,6 @@ import Config from '../../../tests/e2e/config'; import Routes from '../../../tests/e2e/server/routes'; -import type {NetworkCacheMap, TestConfig} from './types'; - -type TestResult = { - /** Name of the test */ - name: string; - - /** The branch where test were running */ - branch?: string; - - /** Duration in milliseconds */ - duration?: number; - - /** Optional, if set indicates that the test run failed and has no valid results. */ - error?: string; - - /** Render count */ - renderCount?: number; -}; +import type {NetworkCacheMap, TestConfig, TestResult} from './types'; type NativeCommandPayload = { text: string; diff --git a/src/libs/E2E/types.ts b/src/libs/E2E/types.ts index 0964938392fb..5185a75625a3 100644 --- a/src/libs/E2E/types.ts +++ b/src/libs/E2E/types.ts @@ -26,4 +26,21 @@ type TestConfig = { [key: string]: string | {autoFocus: boolean}; }; -export type {SigninParams, IsE2ETestSession, NetworkCacheMap, NetworkCacheEntry, TestConfig}; +type TestResult = { + /** Name of the test */ + name: string; + + /** The branch where test were running */ + branch?: string; + + /** Duration in milliseconds */ + duration?: number; + + /** Optional, if set indicates that the test run failed and has no valid results. */ + error?: string; + + /** Render count */ + renderCount?: number; +}; + +export type {SigninParams, IsE2ETestSession, NetworkCacheMap, NetworkCacheEntry, TestConfig, TestResult}; diff --git a/src/libs/Growl.ts b/src/libs/Growl.ts index 55bcf88206e9..3812a155ba1f 100644 --- a/src/libs/Growl.ts +++ b/src/libs/Growl.ts @@ -50,4 +50,6 @@ export default { success, }; +export type {GrowlRef}; + export {growlRef, setIsReady}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index a986e2995156..585713243d3f 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -6,6 +6,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import KeyboardShortcut from '@libs/KeyboardShortcut'; +import Log from '@libs/Log'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; @@ -126,6 +127,7 @@ function handleNetworkReconnect() { if (isLoadingApp) { App.openApp(); } else { + Log.info('[handleNetworkReconnect] Sending ReconnectApp'); App.reconnectApp(lastUpdateIDAppliedToClient); } } @@ -189,6 +191,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie if (SessionUtils.didUserLogInDuringSession()) { App.openApp(); } else { + Log.info('[AuthScreens] Sending ReconnectApp'); App.reconnectApp(initialLastUpdateIDAppliedToClient); } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index d56e38564149..7df1da23d068 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -186,13 +186,20 @@ const NewTeachersUniteNavigator = createModalStackNavigator require('../../../pages/TeachersUnite/ImTeacherPage').default as React.ComponentType, }); -const AccountSettingsModalStackNavigator = createModalStackNavigator( +const WorkspaceSettingsModalStackNavigator = createModalStackNavigator( { - [SCREENS.SETTINGS.PREFERENCES.ROOT]: () => require('../../../pages/settings/Preferences/PreferencesPage').default as React.ComponentType, - [SCREENS.SETTINGS.SECURITY]: () => require('../../../pages/settings/Security/SecuritySettingsPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.ROOT]: () => require('../../../pages/settings/Profile/ProfilePage').default as React.ComponentType, - [SCREENS.SETTINGS.WALLET.ROOT]: () => require('../../../pages/settings/Wallet/WalletPage').default as React.ComponentType, - [SCREENS.SETTINGS.ABOUT]: () => require('../../../pages/settings/AboutPage/AboutPage').default as React.ComponentType, + [SCREENS.WORKSPACE.PROFILE]: () => require('../../../pages/workspace/WorkspaceProfilePage').default as React.ComponentType, + [SCREENS.WORKSPACE.CARD]: () => require('../../../pages/workspace/card/WorkspaceCardPage').default as React.ComponentType, + [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../pages/workspace/workflows/WorkspaceWorkflowsPage').default as React.ComponentType, + [SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../pages/workspace/reimburse/WorkspaceReimbursePage').default as React.ComponentType, + [SCREENS.WORKSPACE.BILLS]: () => require('../../../pages/workspace/bills/WorkspaceBillsPage').default as React.ComponentType, + [SCREENS.WORKSPACE.INVOICES]: () => require('../../../pages/workspace/invoices/WorkspaceInvoicesPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TRAVEL]: () => require('../../../pages/workspace/travel/WorkspaceTravelPage').default as React.ComponentType, + [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../pages/workspace/WorkspaceMembersPage').default as React.ComponentType, + [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../pages/workspace/categories/WorkspaceCategoriesPage').default as React.ComponentType, + [SCREENS.WORKSPACE.MORE_FEATURES]: () => require('../../../pages/workspace/WorkspaceMoreFeaturesPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAGS]: () => require('../../../pages/workspace/tags/WorkspaceTagsPage').default as React.ComponentType, + [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default as React.ComponentType, }, (styles) => ({cardStyle: styles.navigationScreenCardStyle, headerShown: false}), ); @@ -245,6 +252,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/WorkspaceInvitePage').default as React.ComponentType, [SCREENS.WORKSPACE.WORKFLOWS_APPROVER]: () => require('../../../pages/workspace/workflows/WorkspaceWorkflowsApproverPage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE_MESSAGE]: () => require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType, + [SCREENS.WORKSPACE.WORKFLOWS_PAYER]: () => require('../../../pages/workspace/workflows/WorkspaceWorkflowsPayerPage').default as React.ComponentType, [SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType, [SCREENS.WORKSPACE.DESCRIPTION]: () => require('../../../pages/workspace/WorkspaceProfileDescriptionPage').default as React.ComponentType, [SCREENS.WORKSPACE.SHARE]: () => require('../../../pages/workspace/WorkspaceProfileSharePage').default as React.ComponentType, @@ -256,6 +264,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/categories/CreateCategoryPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAGS_SETTINGS]: () => require('../../../pages/workspace/tags/WorkspaceTagsSettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAGS_EDIT]: () => require('../../../pages/workspace/tags/WorkspaceEditTagsPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAG_CREATE]: () => require('../../../pages/workspace/tags/WorkspaceCreateTagPage').default as React.ComponentType, [SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType, [SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType, [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default as React.ComponentType, @@ -312,7 +321,6 @@ const ProcessMoneyRequestHoldStackNavigator = createModalStackNavigator({ }); export { - AccountSettingsModalStackNavigator, AddPersonalBankAccountModalStackNavigator, DetailsModalStackNavigator, OnboardEngagementModalStackNavigator, @@ -341,4 +349,5 @@ export { TaskModalStackNavigator, WalletStatementStackNavigator, ProcessMoneyRequestHoldStackNavigator, + WorkspaceSettingsModalStackNavigator, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx index ce03a8d5bcba..87a441f16ddb 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx @@ -4,12 +4,11 @@ import React from 'react'; import createCustomBottomTabNavigator from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import type {BottomTabNavigatorParamList} from '@libs/Navigation/types'; -import AllSettingsScreen from '@pages/home/sidebar/AllSettingsScreen'; import SidebarScreen from '@pages/home/sidebar/SidebarScreen'; import SCREENS from '@src/SCREENS'; import ActiveRouteContext from './ActiveRouteContext'; -const loadWorkspaceInitialPage = () => require('../../../../pages/workspace/WorkspaceInitialPage').default as React.ComponentType; +const loadInitialSettingsPage = () => require('../../../../pages/settings/InitialSettingsPage').default as React.ComponentType; const Tab = createCustomBottomTabNavigator(); @@ -20,6 +19,7 @@ const screenOptions: StackNavigationOptions = { function BottomTabNavigator() { const activeRoute = useNavigationState(getTopmostCentralPaneRoute); + return ( @@ -28,12 +28,8 @@ function BottomTabNavigator() { component={SidebarScreen} /> - diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx index 5a3af07a3d5a..16f403342a58 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx @@ -13,20 +13,13 @@ const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : type Screens = Partial React.ComponentType>>; -const workspaceSettingsScreens = { +const settingsScreens = { [SCREENS.SETTINGS.WORKSPACES]: () => require('../../../../../pages/workspace/WorkspacesListPage').default as React.ComponentType, - [SCREENS.WORKSPACE.PROFILE]: () => require('../../../../../pages/workspace/WorkspaceProfilePage').default as React.ComponentType, - [SCREENS.WORKSPACE.CARD]: () => require('../../../../../pages/workspace/card/WorkspaceCardPage').default as React.ComponentType, - [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../../pages/workspace/workflows/WorkspaceWorkflowsPage').default as React.ComponentType, - [SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../../../pages/workspace/reimburse/WorkspaceReimbursePage').default as React.ComponentType, - [SCREENS.WORKSPACE.BILLS]: () => require('../../../../../pages/workspace/bills/WorkspaceBillsPage').default as React.ComponentType, - [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default as React.ComponentType, - [SCREENS.WORKSPACE.TRAVEL]: () => require('../../../../../pages/workspace/travel/WorkspaceTravelPage').default as React.ComponentType, - [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../../pages/workspace/WorkspaceMembersPage').default as React.ComponentType, - [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../../../pages/workspace/categories/WorkspaceCategoriesPage').default as React.ComponentType, - [SCREENS.WORKSPACE.MORE_FEATURES]: () => require('../../../../../pages/workspace/WorkspaceMoreFeaturesPage').default as React.ComponentType, - [SCREENS.WORKSPACE.TAGS]: () => require('../../../../../pages/workspace/tags/WorkspaceTagsPage').default as React.ComponentType, - [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default as React.ComponentType, + [SCREENS.SETTINGS.PREFERENCES.ROOT]: () => require('../../../../../pages/settings/Preferences/PreferencesPage').default as React.ComponentType, + [SCREENS.SETTINGS.SECURITY]: () => require('../../../../../pages/settings/Security/SecuritySettingsPage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.ROOT]: () => require('../../../../../pages/settings/Profile/ProfilePage').default as React.ComponentType, + [SCREENS.SETTINGS.WALLET.ROOT]: () => require('../../../../../pages/settings/Wallet/WalletPage').default as React.ComponentType, + [SCREENS.SETTINGS.ABOUT]: () => require('../../../../../pages/settings/AboutPage/AboutPage').default as React.ComponentType, } satisfies Screens; function BaseCentralPaneNavigator() { @@ -46,8 +39,7 @@ function BaseCentralPaneNavigator() { initialParams={{openOnAdminRoom: openOnAdminRoom === 'true' || undefined}} component={ReportScreenWrapper} /> - - {Object.entries(workspaceSettingsScreens).map(([screenName, componentGetter]) => ( + {Object.entries(settingsScreens).map(([screenName, componentGetter]) => ( require('../../../../pages/settings/InitialSettingsPage').default as React.ComponentType; +const loadWorkspaceInitialPage = () => require('../../../../pages/workspace/WorkspaceInitialPage').default as React.ComponentType; const RootStack = createCustomFullScreenNavigator(); @@ -22,14 +22,14 @@ function FullScreenNavigator() { diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx index 58d9efb43df5..3a59e42bcca1 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx @@ -12,11 +12,11 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@libs/actions/Session'; -import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {RootStackParamList, State} from '@libs/Navigation/types'; -import {checkIfWorkspaceSettingsTabHasRBR, getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; +import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; +import BottomTabAvatar from '@pages/home/sidebar/BottomTabAvatar'; import BottomTabBarFloatingActionButton from '@pages/home/sidebar/BottomTabBarFloatingActionButton'; import variables from '@styles/variables'; import * as Welcome from '@userActions/Welcome'; @@ -43,14 +43,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps const navigationState = navigation.getState() as State | undefined; const routes = navigationState?.routes; const currentRoute = routes?.[navigationState?.index ?? 0]; - const bottomTabRoute = getTopmostBottomTabRoute(navigationState); - if ( - // When we are redirected to the Settings tab from the OldDot, we don't want to call the Welcome.show() method. - // To prevent this, the value of the bottomTabRoute?.name is checked here - bottomTabRoute?.name === SCREENS.WORKSPACE.INITIAL || - Boolean(currentRoute && currentRoute.name !== NAVIGATORS.BOTTOM_TAB_NAVIGATOR && currentRoute.name !== NAVIGATORS.CENTRAL_PANE_NAVIGATOR) || - Session.isAnonymousUser() - ) { + if (Boolean(currentRoute && currentRoute.name !== NAVIGATORS.BOTTOM_TAB_NAVIGATOR && currentRoute.name !== NAVIGATORS.CENTRAL_PANE_NAVIGATOR) || Session.isAnonymousUser()) { return; } @@ -64,22 +57,20 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps return topmostBottomTabRoute?.name ?? SCREENS.HOME; }); - const shouldShowWorkspaceRedBrickRoad = checkIfWorkspaceSettingsTabHasRBR(activeWorkspaceID) && currentTabName === SCREENS.HOME; - - const chatTabBrickRoad = currentTabName !== SCREENS.HOME ? getChatTabBrickRoad(activeWorkspaceID) : undefined; + const chatTabBrickRoad = getChatTabBrickRoad(activeWorkspaceID); return ( - - { - Navigation.navigate(ROUTES.HOME); - }} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('common.chats')} - wrapperStyle={styles.flexGrow1} - style={styles.bottomTabBarItem} - > + { + Navigation.navigate(ROUTES.HOME); + }} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('common.chats')} + wrapperStyle={styles.flex1} + style={styles.bottomTabBarItem} + > + )} - - + + + - - - interceptAnonymousUser(() => - activeWorkspaceID ? Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(activeWorkspaceID)) : Navigation.navigate(ROUTES.ALL_SETTINGS), - ) - } - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('common.settings')} - wrapperStyle={styles.flexGrow1} - style={styles.bottomTabBarItem} - > - - - {shouldShowWorkspaceRedBrickRoad && } - - - + + + ); } diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx index 4ed8869c1eaa..38bfe4af9ab6 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx @@ -1,36 +1,87 @@ import React from 'react'; import {View} from 'react-native'; -import Search from '@components/Search'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import Breadcrumbs from '@components/Breadcrumbs'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithoutFeedback} from '@components/Pressable'; +import Tooltip from '@components/Tooltip'; import WorkspaceSwitcherButton from '@components/WorkspaceSwitcherButton'; -import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import SignInOrAvatarWithOptionalStatus from '@pages/home/sidebar/SignInOrAvatarWithOptionalStatus'; +import SignInButton from '@pages/home/sidebar/SignInButton'; import * as Session from '@userActions/Session'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; -function TopBar() { +type TopBarOnyxProps = { + policy: OnyxEntry; +}; + +// eslint-disable-next-line react/no-unused-prop-types +type TopBarProps = {activeWorkspaceID?: string} & TopBarOnyxProps; + +function TopBar({policy}: TopBarProps) { const styles = useThemeStyles(); + const theme = useTheme(); const {translate} = useLocalize(); - const {activeWorkspaceID} = useActiveWorkspace(); + + const headerBreadcrumb = policy?.name + ? {type: CONST.BREADCRUMB_TYPE.STRONG, text: policy.name} + : { + type: CONST.BREADCRUMB_TYPE.ROOT, + }; return ( - - - Navigation.navigate(ROUTES.SEARCH))} - containerStyle={[styles.flex1]} - /> - + + + + + + + + + + {Session.isAnonymousUser() ? ( + + ) : ( + + Navigation.navigate(ROUTES.SEARCH))} + > + + + + )} + ); } TopBar.displayName = 'TopBar'; -export default TopBar; +export default withOnyx({ + policy: { + key: ({activeWorkspaceID}) => `${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID}`, + }, +})(TopBar); diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx index bd32c6cab73c..8c53027cf713 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx @@ -9,7 +9,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {NavigationStateRoute} from '@libs/Navigation/types'; import SCREENS from '@src/SCREENS'; import BottomTabBar from './BottomTabBar'; -import TopBar from './TopBar'; type CustomNavigatorProps = DefaultNavigatorOptions, StackNavigationOptions, StackNavigationEventMap> & { initialRouteName: string; @@ -52,7 +51,6 @@ function CustomBottomTabNavigator({initialRouteName, children, screenOptions, .. shouldShowOfflineIndicator={false} > - | PartialState>; -const isAtLeastOneInState = (state: StackState, screenName: string): boolean => !!state.routes.find((route) => route.name === screenName); +const isAtLeastOneInState = (state: StackState, screenName: string): boolean => state.routes.some((route) => route.name === screenName); function adaptStateIfNecessary(state: StackState) { const isNarrowLayout = getIsNarrowLayout(); + const workspaceCentralPane = state.routes.at(-1); + const topmostWorkspaceCentralPaneRoute = workspaceCentralPane?.state?.routes[0]; - // There should always be SETTINGS.ROOT screen in the state to make sure go back works properly if we deeplinkg to a subpage of settings. - if (!isAtLeastOneInState(state, SCREENS.SETTINGS.ROOT)) { + // When a screen from the FullScreenNavigator is opened from the deeplink then params should be passed to SCREENS.WORKSPACE.INITIAL from the variable defined below. + const workspacesCentralPaneParams = + workspaceCentralPane?.params && 'params' in workspaceCentralPane.params ? (workspaceCentralPane.params.params as Record) : undefined; + + // There should always be WORKSPACE.INITIAL screen in the state to make sure go back works properly if we deeplinkg to a subpage of settings. + if (!isAtLeastOneInState(state, SCREENS.WORKSPACE.INITIAL)) { // @ts-expect-error Updating read only property // noinspection JSConstantReassignment state.stale = true; // eslint-disable-line @@ -20,27 +26,30 @@ function adaptStateIfNecessary(state: StackState) { // This is necessary for ts to narrow type down to PartialState. if (state.stale === true) { // Unshift the root screen to fill left pane. - state.routes.unshift({name: SCREENS.SETTINGS.ROOT}); + state.routes.unshift({ + name: SCREENS.WORKSPACE.INITIAL, + params: topmostWorkspaceCentralPaneRoute?.params ?? workspacesCentralPaneParams, + }); } } // If the screen is wide, there should be at least two screens inside: - // - SETINGS.ROOT to cover left pane. - // - SETTINGS_CENTRAL_PANE to cover central pane. + // - WORKSPACE.INITIAL to cover left pane. + // - WORKSPACES_CENTRAL_PANE to cover central pane. if (!isNarrowLayout) { - if (!isAtLeastOneInState(state, SCREENS.SETTINGS_CENTRAL_PANE)) { + if (!isAtLeastOneInState(state, SCREENS.WORKSPACES_CENTRAL_PANE)) { // @ts-expect-error Updating read only property // noinspection JSConstantReassignment state.stale = true; // eslint-disable-line - // Push the default settings central pane screen. if (state.stale === true) { state.routes.push({ - name: SCREENS.SETTINGS_CENTRAL_PANE, + name: SCREENS.WORKSPACES_CENTRAL_PANE, state: { routes: [ { - name: SCREENS.SETTINGS.PROFILE.ROOT, + name: SCREENS.WORKSPACE.PROFILE, + params: state.routes[0]?.params, }, ], }, diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx index fb7ae24947c2..f35c609402b0 100644 --- a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx @@ -2,34 +2,12 @@ import type {ParamListBase, StackActionHelpers, StackNavigationState} from '@rea import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native'; import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; import {StackView} from '@react-navigation/stack'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import useWindowDimensions from '@hooks/useWindowDimensions'; import navigationRef from '@libs/Navigation/navigationRef'; -import SCREENS from '@src/SCREENS'; import CustomFullScreenRouter from './CustomFullScreenRouter'; import type {FullScreenNavigatorProps, FullScreenNavigatorRouterOptions} from './types'; -type Routes = StackNavigationState['routes']; -function reduceReportRoutes(routes: Routes): Routes { - const result: Routes = []; - let count = 0; - const reverseRoutes = [...routes].reverse(); - - reverseRoutes.forEach((route) => { - if (route.name === SCREENS.SETTINGS_CENTRAL_PANE) { - // Remove all report routes except the last 3. This will improve performance. - if (count < 3) { - result.push(route); - count++; - } - } else { - result.push(route); - } - }); - - return result.reverse(); -} - function CustomFullScreenNavigator(props: FullScreenNavigatorProps) { const {navigation, state, descriptors, NavigationContent} = useNavigationBuilder< StackNavigationState, @@ -45,16 +23,6 @@ function CustomFullScreenNavigator(props: FullScreenNavigatorProps) { const {isSmallScreenWidth} = useWindowDimensions(); - const stateToRender = useMemo(() => { - const result = reduceReportRoutes(state.routes); - - return { - ...state, - index: result.length - 1, - routes: [...result], - }; - }, [state]); - useEffect(() => { if (!navigationRef.isReady()) { return; @@ -69,7 +37,7 @@ function CustomFullScreenNavigator(props: FullScreenNavigatorProps) { diff --git a/src/libs/Navigation/FreezeWrapper.tsx b/src/libs/Navigation/FreezeWrapper.tsx index 9bb72a34588b..bb6ab107373b 100644 --- a/src/libs/Navigation/FreezeWrapper.tsx +++ b/src/libs/Navigation/FreezeWrapper.tsx @@ -1,7 +1,6 @@ import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import React, {useEffect, useRef, useState} from 'react'; import {Freeze} from 'react-freeze'; -import {InteractionManager} from 'react-native'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; type FreezeWrapperProps = ChildrenProps & { @@ -29,7 +28,7 @@ function FreezeWrapper({keepVisible = false, children}: FreezeWrapperProps) { // we don't want to freeze the screen if it's the previous screen because the freeze placeholder // would be visible at the beginning of the back animation then if ((navigation.getState()?.index ?? 0) - (screenIndexRef.current ?? 0) > 1) { - InteractionManager.runAfterInteractions(() => setIsScreenBlurred(true)); + setIsScreenBlurred(true); } else { setIsScreenBlurred(false); } diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 4cd6a141bd3b..c55145a5d580 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -19,7 +19,7 @@ import linkingConfig from './linkingConfig'; import linkTo from './linkTo'; import navigationRef from './navigationRef'; import switchPolicyID from './switchPolicyID'; -import type {State, StateOrRoute, SwitchPolicyIDParams} from './types'; +import type {NavigationStateRoute, State, StateOrRoute, SwitchPolicyIDParams} from './types'; let resolveNavigationIsReadyPromise: () => void; const navigationIsReadyPromise = new Promise((resolve) => { @@ -234,6 +234,18 @@ function goBack(fallbackRoute?: Route, shouldEnforceFallback = false, shouldPopT navigationRef.current.goBack(); } +/** + * Reset the navigation state to Home page + */ +function resetToHome() { + const rootState = navigationRef.getRootState(); + const bottomTabKey = rootState.routes.find((item: NavigationStateRoute) => item.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR)?.state?.key; + if (bottomTabKey) { + navigationRef.dispatch({...StackActions.popToTop(), target: bottomTabKey}); + } + navigationRef.dispatch({...StackActions.popToTop(), target: rootState.key}); +} + /** * Close the full screen modal. */ @@ -366,6 +378,7 @@ export default { parseHybridAppUrl, closeFullScreen, navigateWithSwitchPolicyID, + resetToHome, }; export {navigationRef}; diff --git a/src/libs/Navigation/getTopmostSettingsCentralPaneName.ts b/src/libs/Navigation/getTopmostWorkspacesCentralPaneName.ts similarity index 76% rename from src/libs/Navigation/getTopmostSettingsCentralPaneName.ts rename to src/libs/Navigation/getTopmostWorkspacesCentralPaneName.ts index 0ddea6588ef6..db11368c1345 100644 --- a/src/libs/Navigation/getTopmostSettingsCentralPaneName.ts +++ b/src/libs/Navigation/getTopmostWorkspacesCentralPaneName.ts @@ -2,12 +2,12 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; import SCREENS from '@src/SCREENS'; // Get the name of topmost report in the navigation stack. -function getTopmostSettingsCentralPaneName(state: NavigationState | PartialState): string | undefined { +function getTopmostWorkspacesCentralPaneName(state: NavigationState | PartialState): string | undefined { if (!state) { return; } - const topmostCentralPane = state.routes.filter((route) => typeof route !== 'number' && 'name' in route && route.name === SCREENS.SETTINGS_CENTRAL_PANE).at(-1); + const topmostCentralPane = state.routes.filter((route) => typeof route !== 'number' && 'name' in route && route.name === SCREENS.WORKSPACES_CENTRAL_PANE).at(-1); if (!topmostCentralPane) { return; @@ -24,4 +24,4 @@ function getTopmostSettingsCentralPaneName(state: NavigationState | PartialState return topmostCentralPane.state?.routes.at(-1)?.name; } -export default getTopmostSettingsCentralPaneName; +export default getTopmostWorkspacesCentralPaneName; diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts index 371ea89df2e2..2a00895f0492 100644 --- a/src/libs/Navigation/linkTo.ts +++ b/src/libs/Navigation/linkTo.ts @@ -6,7 +6,6 @@ import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import type {Route} from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import getActionsFromPartialDiff from './AppNavigator/getActionsFromPartialDiff'; import getPartialStateDiff from './AppNavigator/getPartialStateDiff'; import dismissModal from './dismissModal'; @@ -119,7 +118,6 @@ export default function linkTo(navigation: NavigationContainerRef)?.name === SCREENS.WORKSPACE.INITIAL && path.includes('workspace'); + const isFullScreenOnTop = rootState.routes?.at(-1)?.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR; - if (policyID && !isWorkspaceSettingsOpened) { + if (policyID && !isFullScreenOnTop) { // The stateFromPath doesn't include proper path if there is a policy passed with /w/id. // We need to replace the path in the state with the proper one. // To avoid this hacky solution we may want to create custom getActionFromState function in the future. diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts index 743bf2e0cff1..95233bfed079 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -2,12 +2,40 @@ import type {CentralPaneName} from '@libs/Navigation/types'; import SCREENS from '@src/SCREENS'; const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = { - [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE], - [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], - [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE, SCREENS.WORKSPACE.MEMBER_DETAILS, SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE_SELECTION], - [SCREENS.WORKSPACE.WORKFLOWS]: [SCREENS.WORKSPACE.WORKFLOWS_APPROVER, SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY, SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET], - [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT], - [SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS], + [SCREENS.SETTINGS.PROFILE.ROOT]: [ + SCREENS.SETTINGS.PROFILE.DISPLAY_NAME, + SCREENS.SETTINGS.PROFILE.CONTACT_METHODS, + SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS, + SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD, + SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER, + SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE, + SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME, + SCREENS.SETTINGS.PROFILE.STATUS, + SCREENS.SETTINGS.PROFILE.PRONOUNS, + SCREENS.SETTINGS.PROFILE.TIMEZONE, + SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT, + SCREENS.SETTINGS.PROFILE.LEGAL_NAME, + SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH, + SCREENS.SETTINGS.PROFILE.ADDRESS, + SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY, + SCREENS.SETTINGS.SHARE_CODE, + ], + [SCREENS.SETTINGS.PREFERENCES.ROOT]: [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE, SCREENS.SETTINGS.PREFERENCES.LANGUAGE, SCREENS.SETTINGS.PREFERENCES.THEME], + [SCREENS.SETTINGS.WALLET.ROOT]: [ + SCREENS.SETTINGS.WALLET.DOMAIN_CARD, + SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.NAME, + SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.PHONE, + SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.ADDRESS, + SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.CONFIRM, + SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE, + SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT, + SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS, + SCREENS.SETTINGS.WALLET.CARD_ACTIVATE, + SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD, + SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS, + ], + [SCREENS.SETTINGS.SECURITY]: [SCREENS.SETTINGS.TWO_FACTOR_AUTH, SCREENS.SETTINGS.CLOSE], + [SCREENS.SETTINGS.ABOUT]: [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS, SCREENS.SETTINGS.TROUBLESHOOT], }; export default CENTRAL_PANE_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index f79e275007d7..fd108f2c95f3 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -2,39 +2,17 @@ import type {FullScreenName} from '@libs/Navigation/types'; import SCREENS from '@src/SCREENS'; const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { - [SCREENS.SETTINGS.PROFILE.ROOT]: [ - SCREENS.SETTINGS.PROFILE.DISPLAY_NAME, - SCREENS.SETTINGS.PROFILE.CONTACT_METHODS, - SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS, - SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD, - SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER, - SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE, - SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME, - SCREENS.SETTINGS.PROFILE.STATUS, - SCREENS.SETTINGS.PROFILE.PRONOUNS, - SCREENS.SETTINGS.PROFILE.TIMEZONE, - SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT, - SCREENS.SETTINGS.PROFILE.LEGAL_NAME, - SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH, - SCREENS.SETTINGS.PROFILE.ADDRESS, - SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY, + [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE], + [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], + [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE, SCREENS.WORKSPACE.MEMBER_DETAILS, SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE_SELECTION], + [SCREENS.WORKSPACE.WORKFLOWS]: [ + SCREENS.WORKSPACE.WORKFLOWS_APPROVER, + SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY, + SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET, + SCREENS.WORKSPACE.WORKFLOWS_PAYER, ], - [SCREENS.SETTINGS.PREFERENCES.ROOT]: [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE, SCREENS.SETTINGS.PREFERENCES.LANGUAGE, SCREENS.SETTINGS.PREFERENCES.THEME], - [SCREENS.SETTINGS.WALLET.ROOT]: [ - SCREENS.SETTINGS.WALLET.DOMAIN_CARD, - SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.NAME, - SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.PHONE, - SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.ADDRESS, - SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.CONFIRM, - SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE, - SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT, - SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS, - SCREENS.SETTINGS.WALLET.CARD_ACTIVATE, - SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD, - SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS, - ], - [SCREENS.SETTINGS.SECURITY]: [SCREENS.SETTINGS.TWO_FACTOR_AUTH, SCREENS.SETTINGS.CLOSE], - [SCREENS.SETTINGS.ABOUT]: [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS, SCREENS.SETTINGS.TROUBLESHOOT], + [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT, SCREENS.WORKSPACE.TAG_CREATE], + [SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS], }; export default FULL_SCREEN_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts index b2939cf38d9f..78a644ab4aee 100755 --- a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts @@ -3,20 +3,13 @@ import SCREENS from '@src/SCREENS'; const TAB_TO_CENTRAL_PANE_MAPPING: Record = { [SCREENS.HOME]: [SCREENS.REPORT], - [SCREENS.ALL_SETTINGS]: [SCREENS.SETTINGS.WORKSPACES], - [SCREENS.WORKSPACE.INITIAL]: [ - SCREENS.WORKSPACE.PROFILE, - SCREENS.WORKSPACE.CARD, - SCREENS.WORKSPACE.WORKFLOWS, - SCREENS.WORKSPACE.REIMBURSE, - SCREENS.WORKSPACE.BILLS, - SCREENS.WORKSPACE.INVOICES, - SCREENS.WORKSPACE.TRAVEL, - SCREENS.WORKSPACE.MEMBERS, - SCREENS.WORKSPACE.CATEGORIES, - SCREENS.WORKSPACE.TAGS, - SCREENS.WORKSPACE.MORE_FEATURES, - SCREENS.WORKSPACE.DISTANCE_RATES, + [SCREENS.SETTINGS.ROOT]: [ + SCREENS.SETTINGS.PROFILE.ROOT, + SCREENS.SETTINGS.PREFERENCES.ROOT, + SCREENS.SETTINGS.SECURITY, + SCREENS.SETTINGS.WALLET.ROOT, + SCREENS.SETTINGS.ABOUT, + SCREENS.SETTINGS.WORKSPACES, ], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 97d7650a9043..1461c27e03e0 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -31,10 +31,8 @@ const config: LinkingOptions['config'] = { initialRouteName: SCREENS.HOME, screens: { [SCREENS.HOME]: ROUTES.HOME, - [SCREENS.ALL_SETTINGS]: ROUTES.ALL_SETTINGS, - [SCREENS.WORKSPACE.INITIAL]: { - path: ROUTES.WORKSPACE_INITIAL.route, - exact: true, + [SCREENS.SETTINGS.ROOT]: { + path: ROUTES.SETTINGS, }, }, }, @@ -42,42 +40,27 @@ const config: LinkingOptions['config'] = { [NAVIGATORS.CENTRAL_PANE_NAVIGATOR]: { screens: { [SCREENS.REPORT]: ROUTES.REPORT_WITH_ID.route, - - [SCREENS.SETTINGS.WORKSPACES]: ROUTES.SETTINGS_WORKSPACES, - [SCREENS.WORKSPACE.PROFILE]: ROUTES.WORKSPACE_PROFILE.route, - [SCREENS.WORKSPACE.CARD]: { - path: ROUTES.WORKSPACE_CARD.route, - }, - [SCREENS.WORKSPACE.WORKFLOWS]: { - path: ROUTES.WORKSPACE_WORKFLOWS.route, - }, - [SCREENS.WORKSPACE.REIMBURSE]: { - path: ROUTES.WORKSPACE_REIMBURSE.route, - }, - [SCREENS.WORKSPACE.BILLS]: { - path: ROUTES.WORKSPACE_BILLS.route, - }, - [SCREENS.WORKSPACE.INVOICES]: { - path: ROUTES.WORKSPACE_INVOICES.route, - }, - [SCREENS.WORKSPACE.TRAVEL]: { - path: ROUTES.WORKSPACE_TRAVEL.route, - }, - [SCREENS.WORKSPACE.MEMBERS]: { - path: ROUTES.WORKSPACE_MEMBERS.route, + [SCREENS.SETTINGS.PROFILE.ROOT]: { + path: ROUTES.SETTINGS_PROFILE, + exact: true, }, - [SCREENS.WORKSPACE.CATEGORIES]: { - path: ROUTES.WORKSPACE_CATEGORIES.route, + [SCREENS.SETTINGS.PREFERENCES.ROOT]: { + path: ROUTES.SETTINGS_PREFERENCES, + exact: true, }, - [SCREENS.WORKSPACE.MORE_FEATURES]: { - path: ROUTES.WORKSPACE_MORE_FEATURES.route, + [SCREENS.SETTINGS.SECURITY]: { + path: ROUTES.SETTINGS_SECURITY, + exact: true, }, - [SCREENS.WORKSPACE.TAGS]: { - path: ROUTES.WORKSPACE_TAGS.route, + [SCREENS.SETTINGS.WALLET.ROOT]: { + path: ROUTES.SETTINGS_WALLET, + exact: true, }, - [SCREENS.WORKSPACE.DISTANCE_RATES]: { - path: ROUTES.WORKSPACE_DISTANCE_RATES.route, + [SCREENS.SETTINGS.ABOUT]: { + path: ROUTES.SETTINGS_ABOUT, + exact: true, }, + [SCREENS.SETTINGS.WORKSPACES]: ROUTES.SETTINGS_WORKSPACES, }, }, [SCREENS.NOT_FOUND]: '*', @@ -288,6 +271,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { path: ROUTES.WORKSPACE_CATEGORIES_SETTINGS.route, }, + [SCREENS.WORKSPACE.WORKFLOWS_PAYER]: { + path: ROUTES.WORKSPACE_WORKFLOWS_PAYER.route, + }, [SCREENS.WORKSPACE.MEMBER_DETAILS]: { path: ROUTES.WORKSPACE_MEMBER_DETAILS.route, }, @@ -303,6 +289,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.TAGS_EDIT]: { path: ROUTES.WORKSPACE_EDIT_TAGS.route, }, + [SCREENS.WORKSPACE.TAG_CREATE]: { + path: ROUTES.WORKSPACE_TAG_CREATE.route, + }, [SCREENS.REIMBURSEMENT_ACCOUNT]: { path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.route, exact: true, @@ -557,30 +546,44 @@ const config: LinkingOptions['config'] = { [NAVIGATORS.FULL_SCREEN_NAVIGATOR]: { screens: { - [SCREENS.SETTINGS.ROOT]: { - path: ROUTES.SETTINGS, + [SCREENS.WORKSPACE.INITIAL]: { + path: ROUTES.WORKSPACE_INITIAL.route, }, - [SCREENS.SETTINGS_CENTRAL_PANE]: { + [SCREENS.WORKSPACES_CENTRAL_PANE]: { screens: { - [SCREENS.SETTINGS.PROFILE.ROOT]: { - path: ROUTES.SETTINGS_PROFILE, - exact: true, + [SCREENS.WORKSPACE.PROFILE]: ROUTES.WORKSPACE_PROFILE.route, + [SCREENS.WORKSPACE.CARD]: { + path: ROUTES.WORKSPACE_CARD.route, }, - [SCREENS.SETTINGS.PREFERENCES.ROOT]: { - path: ROUTES.SETTINGS_PREFERENCES, - exact: true, + [SCREENS.WORKSPACE.WORKFLOWS]: { + path: ROUTES.WORKSPACE_WORKFLOWS.route, }, - [SCREENS.SETTINGS.SECURITY]: { - path: ROUTES.SETTINGS_SECURITY, - exact: true, + [SCREENS.WORKSPACE.REIMBURSE]: { + path: ROUTES.WORKSPACE_REIMBURSE.route, }, - [SCREENS.SETTINGS.WALLET.ROOT]: { - path: ROUTES.SETTINGS_WALLET, - exact: true, + [SCREENS.WORKSPACE.BILLS]: { + path: ROUTES.WORKSPACE_BILLS.route, }, - [SCREENS.SETTINGS.ABOUT]: { - path: ROUTES.SETTINGS_ABOUT, - exact: true, + [SCREENS.WORKSPACE.INVOICES]: { + path: ROUTES.WORKSPACE_INVOICES.route, + }, + [SCREENS.WORKSPACE.TRAVEL]: { + path: ROUTES.WORKSPACE_TRAVEL.route, + }, + [SCREENS.WORKSPACE.MEMBERS]: { + path: ROUTES.WORKSPACE_MEMBERS.route, + }, + [SCREENS.WORKSPACE.CATEGORIES]: { + path: ROUTES.WORKSPACE_CATEGORIES.route, + }, + [SCREENS.WORKSPACE.MORE_FEATURES]: { + path: ROUTES.WORKSPACE_MORE_FEATURES.route, + }, + [SCREENS.WORKSPACE.TAGS]: { + path: ROUTES.WORKSPACE_TAGS.route, + }, + [SCREENS.WORKSPACE.DISTANCE_RATES]: { + path: ROUTES.WORKSPACE_DISTANCE_RATES.route, }, }, }, diff --git a/src/libs/Navigation/linkingConfig/customGetPathFromState.ts b/src/libs/Navigation/linkingConfig/customGetPathFromState.ts index 4f7023d14db4..4017b1b2b17c 100644 --- a/src/libs/Navigation/linkingConfig/customGetPathFromState.ts +++ b/src/libs/Navigation/linkingConfig/customGetPathFromState.ts @@ -8,20 +8,19 @@ import SCREENS from '@src/SCREENS'; const removePolicyIDParamFromState = (state: State) => { const stateCopy = _.cloneDeep(state); const bottomTabRoute = getTopmostBottomTabRoute(stateCopy); - if (bottomTabRoute?.name === SCREENS.HOME && bottomTabRoute?.params && 'policyID' in bottomTabRoute.params) { + if (bottomTabRoute?.params && 'policyID' in bottomTabRoute.params) { delete bottomTabRoute.params.policyID; } return stateCopy; }; const customGetPathFromState: typeof getPathFromState = (state, options) => { + // For the Home and Settings pages we should remove policyID from the params, because on small screens it's displayed twice in the URL const stateWithoutPolicyID = removePolicyIDParamFromState(state as State); - - // For the Home page we should remove policyID from the params, const path = getPathFromState(stateWithoutPolicyID, options); const policyIDFromState = getPolicyIDFromState(state as State); - const isWorkspaceSettingsOpened = getTopmostBottomTabRoute(state as State)?.name === SCREENS.WORKSPACE.INITIAL && path.includes('workspace'); - return `${policyIDFromState && !isWorkspaceSettingsOpened ? `/w/${policyIDFromState}` : ''}${path}`; + const isHomeOpened = getTopmostBottomTabRoute(state as State)?.name === SCREENS.HOME; + return `${policyIDFromState && isHomeOpened ? `/w/${policyIDFromState}` : ''}${path}`; }; export default customGetPathFromState; diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 5f89b2cd4630..e4a8464d7ddd 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -14,6 +14,10 @@ import getMatchingBottomTabRouteForState from './getMatchingBottomTabRouteForSta import getMatchingCentralPaneRouteForState from './getMatchingCentralPaneRouteForState'; import replacePathInNestedState from './replacePathInNestedState'; +const RHP_SCREENS_OPENED_FROM_LHN = [SCREENS.SETTINGS.SHARE_CODE, SCREENS.SETTINGS.PROFILE.STATUS] as const; + +type RHPScreenOpenedFromLHN = (typeof RHP_SCREENS_OPENED_FROM_LHN)[number]; + type Metainfo = { // Sometimes modal screens don't have information about what should be visible under the overlay. // That means such screen can have different screens under the overlay depending on what was already in the state. @@ -73,14 +77,21 @@ function createCentralPaneNavigator(route: NavigationPartialRoute): NavigationPartialRoute { const routes = []; - routes.push({name: SCREENS.SETTINGS.ROOT}); + const policyID = route?.params && 'policyID' in route.params ? route.params.policyID : undefined; + + // Both routes in FullScreenNavigator should store a policyID in params, so here this param is also passed to the screen displayed in LHN in FullScreenNavigator + routes.push({ + name: SCREENS.WORKSPACE.INITIAL, + params: { + policyID, + }, + }); if (route) { routes.push({ - name: SCREENS.SETTINGS_CENTRAL_PANE, + name: SCREENS.WORKSPACES_CENTRAL_PANE, state: getRoutesWithIndex([route]), }); } - return { name: NAVIGATORS.FULL_SCREEN_NAVIGATOR, state: getRoutesWithIndex(routes), @@ -131,11 +142,6 @@ function getMatchingRootRouteForRHPRoute( return createFullScreenNavigator({name: fullScreenName as FullScreenName, params: route.params}); } } - - // This screen is opened from the LHN of the FullStackNavigator, so in this case we shouldn't push any CentralPane screen - if (route.name === SCREENS.SETTINGS.SHARE_CODE) { - return createFullScreenNavigator(); - } } function getAdaptedState(state: PartialState>, policyID?: string): GetAdaptedStateReturnType { @@ -165,18 +171,25 @@ function getAdaptedState(state: PartialState if (topmostNestedRHPRoute) { let matchingRootRoute = getMatchingRootRouteForRHPRoute(topmostNestedRHPRoute); - + const isRHPScreenOpenedFromLHN = topmostNestedRHPRoute?.name && RHP_SCREENS_OPENED_FROM_LHN.includes(topmostNestedRHPRoute?.name as RHPScreenOpenedFromLHN); // This may happen if this RHP doens't have a route that should be under the overlay defined. - if (!matchingRootRoute) { + if (!matchingRootRoute || isRHPScreenOpenedFromLHN) { metainfo.isCentralPaneAndBottomTabMandatory = false; metainfo.isFullScreenNavigatorMandatory = false; - matchingRootRoute = createCentralPaneNavigator({name: SCREENS.REPORT}); + matchingRootRoute = matchingRootRoute ?? createCentralPaneNavigator({name: SCREENS.REPORT}); } // If the root route is type of FullScreenNavigator, the default bottom tab will be added. const matchingBottomTabRoute = getMatchingBottomTabRouteForState({routes: [matchingRootRoute]}); routes.push(createBottomTabNavigator(matchingBottomTabRoute, policyID)); - routes.push(matchingRootRoute); + // When we open a screen in RHP from FullScreenNavigator, we need to add the appropriate screen in CentralPane. + // Then, when we close FullScreenNavigator, we will be redirected to the correct page in CentralPane. + if (matchingRootRoute.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR) { + routes.push(createCentralPaneNavigator({name: SCREENS.SETTINGS.WORKSPACES})); + } + if (!isNarrowLayout || !isRHPScreenOpenedFromLHN) { + routes.push(matchingRootRoute); + } } routes.push(rhpNavigator); @@ -230,14 +243,18 @@ function getAdaptedState(state: PartialState routes.push( createBottomTabNavigator( { - name: SCREENS.HOME, + name: SCREENS.SETTINGS.ROOT, }, policyID, ), ); - if (!isNarrowLayout) { - routes.push(createCentralPaneNavigator({name: SCREENS.REPORT})); - } + + routes.push( + createCentralPaneNavigator({ + name: SCREENS.SETTINGS.WORKSPACES, + }), + ); + routes.push(fullScreenNavigator); return { @@ -318,7 +335,6 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options) => { const state = getStateFromPath(pathWithoutPolicyID, options) as PartialState>; replacePathInNestedState(state, path); - if (state === undefined) { throw new Error('Unable to parse path'); } diff --git a/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts index ef4cd65942b0..fd45685acf23 100644 --- a/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts +++ b/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts @@ -1,5 +1,6 @@ import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import type {BottomTabName, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; +import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import {CENTRAL_PANE_TO_TAB_MAPPING} from './TAB_TO_CENTRAL_PANE_MAPPING'; @@ -7,6 +8,12 @@ import {CENTRAL_PANE_TO_TAB_MAPPING} from './TAB_TO_CENTRAL_PANE_MAPPING'; function getMatchingBottomTabRouteForState(state: State, policyID?: string): NavigationPartialRoute { const paramsWithPolicyID = policyID ? {policyID} : undefined; const defaultRoute = {name: SCREENS.HOME, params: paramsWithPolicyID}; + const isFullScreenNavigatorOpened = state.routes.some((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); + + if (isFullScreenNavigatorOpened) { + return {name: SCREENS.SETTINGS.ROOT, params: paramsWithPolicyID}; + } + const topmostCentralPaneRoute = getTopmostCentralPaneRoute(state); if (topmostCentralPaneRoute === undefined) { @@ -14,9 +21,6 @@ function getMatchingBottomTabRouteForState(state: State, pol } const tabName = CENTRAL_PANE_TO_TAB_MAPPING[topmostCentralPaneRoute.name]; - if (tabName === SCREENS.WORKSPACE.INITIAL) { - return {name: tabName, params: topmostCentralPaneRoute.params}; - } return {name: tabName, params: paramsWithPolicyID}; } diff --git a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts index d4d558708b63..51cb9e3aa9a5 100644 --- a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts +++ b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts @@ -4,8 +4,6 @@ import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import TAB_TO_CENTRAL_PANE_MAPPING from './TAB_TO_CENTRAL_PANE_MAPPING'; -const WORKSPACES_SCREENS = TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.WORKSPACE.INITIAL].concat(TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.ALL_SETTINGS]); - /** * @param state - react-navigation state */ @@ -55,11 +53,11 @@ function getAlreadyOpenedSettingsScreen(rootState?: State, policyID?: string): k return undefined; } - // If one of the screen from WORKSPACES_SCREENS is now in the navigation state, we can decide which screen we should display. + // If one of the screen from TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.SETTINGS.ROOT] is now in the navigation state, we can decide which screen we should display. // A screen from the navigation state can be pushed to the navigation state again only if it has a matching policyID with the currently selected workspace. // Otherwise, when we switch the workspace, we want to display the initial screen in the settings tab. const alreadyOpenedSettingsTab = rootState.routes - .filter((item) => item.params && 'screen' in item.params && WORKSPACES_SCREENS.includes(item.params.screen as keyof CentralPaneNavigatorParamList)) + .filter((item) => item.params && 'screen' in item.params && TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.SETTINGS.ROOT].includes(item.params.screen as keyof CentralPaneNavigatorParamList)) .at(-1); if (!hasRouteMatchingPolicyID(alreadyOpenedSettingsTab as NavigationPartialRoute, policyID)) { @@ -82,7 +80,7 @@ function getMatchingCentralPaneRouteForState(state: State, r const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name][0]; - if (topmostBottomTabRoute.name === SCREENS.WORKSPACE.INITIAL) { + if (topmostBottomTabRoute.name === SCREENS.SETTINGS.ROOT) { // When we go back to the settings tab without switching the workspace id, we want to return to the previously opened screen const policyID = topmostBottomTabRoute?.params && 'policyID' in topmostBottomTabRoute.params ? (topmostBottomTabRoute.params.policyID as string) : undefined; const screen = getAlreadyOpenedSettingsScreen(rootState, policyID) ?? centralPaneName; diff --git a/src/libs/Navigation/switchPolicyID.ts b/src/libs/Navigation/switchPolicyID.ts index c425beca73fd..685c21d88e79 100644 --- a/src/libs/Navigation/switchPolicyID.ts +++ b/src/libs/Navigation/switchPolicyID.ts @@ -10,7 +10,6 @@ import SCREENS from '@src/SCREENS'; import getStateFromPath from './getStateFromPath'; import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute'; import linkingConfig from './linkingConfig'; -import TAB_TO_CENTRAL_PANE_MAPPING from './linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING'; import type {NavigationRoot, RootStackParamList, StackNavigationAction, State, SwitchPolicyIDParams} from './types'; type ActionPayloadParams = { @@ -62,7 +61,7 @@ function getActionForBottomTabNavigator(action: StackNavigationAction, state: Na }; } -export default function switchPolicyID(navigation: NavigationContainerRef | null, {policyID, route, isPolicyAdmin = false}: SwitchPolicyIDParams) { +export default function switchPolicyID(navigation: NavigationContainerRef | null, {policyID, route}: SwitchPolicyIDParams) { if (!navigation) { throw new Error("Couldn't find a navigation object. Is your component inside a screen in a navigator?"); } @@ -110,7 +109,7 @@ export default function switchPolicyID(navigation: NavigationContainerRef); @@ -122,23 +121,12 @@ export default function switchPolicyID(navigation: NavigationContainerRef; }; -type SettingsCentralPaneNavigatorParamList = { - [SCREENS.SETTINGS.PROFILE.ROOT]: undefined; - [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; - [SCREENS.SETTINGS.SECURITY]: undefined; - [SCREENS.SETTINGS.WALLET.ROOT]: undefined; - [SCREENS.SETTINGS.ABOUT]: undefined; +type WorkspacesCentralPaneNavigatorParamList = { + [SCREENS.WORKSPACE.PROFILE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.CARD]: { + policyID: string; + }; + [SCREENS.WORKSPACE.WORKFLOWS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.WORKFLOWS_APPROVER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REIMBURSE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.BILLS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.INVOICES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TRAVEL]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBERS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.CATEGORIES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MORE_FEATURES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TAGS]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATES]: { + policyID: string; + }; }; type FullScreenNavigatorParamList = { - [SCREENS.SETTINGS.ROOT]: undefined; - [SCREENS.SETTINGS_CENTRAL_PANE]: NavigatorScreenParams; + [SCREENS.WORKSPACE.INITIAL]: { + policyID: string; + }; + [SCREENS.WORKSPACES_CENTRAL_PANE]: NavigatorScreenParams; }; type BottomTabNavigatorParamList = { [SCREENS.HOME]: undefined; - [SCREENS.ALL_SETTINGS]: undefined; - [SCREENS.WORKSPACE.INITIAL]: undefined; + [SCREENS.SETTINGS.ROOT]: undefined; }; type SharedScreensParamList = { @@ -618,7 +624,7 @@ type BottomTabName = keyof BottomTabNavigatorParamList; type CentralPaneName = keyof CentralPaneNavigatorParamList; -type FullScreenName = keyof SettingsCentralPaneNavigatorParamList; +type FullScreenName = keyof WorkspacesCentralPaneNavigatorParamList; type SwitchPolicyIDParams = { policyID?: string; @@ -672,5 +678,7 @@ export type { WorkspaceSwitcherNavigatorParamList, OnboardEngagementNavigatorParamList, SwitchPolicyIDParams, + FullScreenNavigatorParamList, + WorkspacesCentralPaneNavigatorParamList, BackToParams, }; diff --git a/src/libs/Notification/PushNotification/backgroundRefresh/index.android.ts b/src/libs/Notification/PushNotification/backgroundRefresh/index.android.ts index ff63f5aefa2b..15c26b8648fe 100644 --- a/src/libs/Notification/PushNotification/backgroundRefresh/index.android.ts +++ b/src/libs/Notification/PushNotification/backgroundRefresh/index.android.ts @@ -39,6 +39,7 @@ const backgroundRefresh: BackgroundRefresh = () => { * See more here: https://reactnative.dev/docs/headless-js-android */ App.confirmReadyToOpenApp(); + Log.info('[PushNotification] Sending ReconnectApp'); App.reconnectApp(lastUpdateIDAppliedToClient ?? undefined); }) .catch((error) => { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 1aa73e752eae..3de3d1622405 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -31,6 +31,7 @@ import type { import type {Participant} from '@src/types/onyx/IOU'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import times from '@src/utils/times'; import Timing from './actions/Timing'; @@ -480,7 +481,7 @@ function getSearchText( /** * Get an object of error messages keyed by microtime by combining all error objects related to the report. */ -function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry, transactions: OnyxCollection = allTransactions): OnyxCommon.Errors { +function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry): OnyxCommon.Errors { const reportErrors = report?.errors ?? {}; const reportErrorFields = report?.errorFields ?? {}; const reportActionErrors: OnyxCommon.ErrorFields = Object.values(reportActions ?? {}).reduce( @@ -492,7 +493,7 @@ function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry< if (parentReportAction?.actorAccountID === currentUserAccountID && ReportActionUtils.isTransactionThread(parentReportAction)) { const transactionID = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction?.originalMessage?.IOUTransactionID : null; - const transaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; if (TransactionUtils.hasMissingSmartscanFields(transaction ?? null) && !ReportUtils.isSettled(transaction?.reportID)) { reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxError('report.genericSmartscanFailureMessage'); } @@ -976,7 +977,17 @@ function getCategoryListSections( } if (searchInputValue) { - const searchCategories = enabledCategories.filter((category) => category.name.toLowerCase().includes(searchInputValue.toLowerCase())); + const searchCategories: Category[] = []; + + enabledCategories.forEach((category) => { + if (!category.name.toLowerCase().includes(searchInputValue.toLowerCase())) { + return; + } + searchCategories.push({ + ...category, + isSelected: selectedOptions.some((selectedOption) => selectedOption.name === category.name), + }); + }); categorySections.push({ // "Search" section @@ -1764,7 +1775,7 @@ function getShareLogOptions(reports: OnyxCollection, personalDetails: On /** * Build the IOUConfirmation options for showing the payee personalDetail */ -function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: PersonalDetails, amountText: string): PayeePersonalDetails { +function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: PersonalDetails | EmptyObject, amountText?: string): PayeePersonalDetails { const formattedLogin = LocalePhoneNumber.formatPhoneNumber(personalDetail.login ?? ''); return { text: PersonalDetailsUtils.getDisplayNameOrDefault(personalDetail, formattedLogin), @@ -1777,7 +1788,7 @@ function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: Person id: personalDetail.accountID, }, ], - descriptiveText: amountText, + descriptiveText: amountText ?? '', login: personalDetail.login ?? '', accountID: personalDetail.accountID, keyForList: String(personalDetail.accountID), @@ -2065,4 +2076,4 @@ export { getShareLogOptions, }; -export type {MemberForList, CategorySection, GetOptions}; +export type {MemberForList, CategorySection, GetOptions, PayeePersonalDetails}; diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 9dd60eeebcef..65aadd440010 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -26,6 +26,13 @@ Onyx.connect({ function getDisplayNameOrDefault(passedPersonalDetails?: Partial | null, defaultValue = '', shouldFallbackToHidden = true, shouldAddCurrentUserPostfix = false): string { let displayName = passedPersonalDetails?.displayName ? passedPersonalDetails.displayName.replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '') : ''; + + // If the displayName is not set by the user, the backend sets the diplayName same as the login so + // we need to remove the sms domain from the displayName if it is an sms login. + if (displayName === passedPersonalDetails?.login && Str.isSMSLogin(passedPersonalDetails?.login)) { + displayName = Str.removeSMSDomain(displayName); + } + if (shouldAddCurrentUserPostfix && !!displayName) { displayName = `${displayName} (${Localize.translateLocal('common.you').toLowerCase()})`; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ec31889146f8..8dc1c9967f13 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -31,6 +31,7 @@ import type { Task, Transaction, TransactionViolation, + UserWallet, } from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -51,6 +52,7 @@ import type {Receipt, TransactionChanges, WaypointCollection} from '@src/types/o import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; +import * as store from './actions/ReimbursementAccount/store'; import * as CollectionUtils from './CollectionUtils'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; @@ -424,6 +426,8 @@ type AncestorIDs = { reportActionsIDs: string[]; }; +type MissingPaymentMethod = 'bankAccount' | 'wallet'; + let currentUserEmail: string | undefined; let currentUserPrivateDomain: string | undefined; let currentUserAccountID: number | undefined; @@ -917,7 +921,7 @@ function isChatThread(report: OnyxEntry): boolean { } function isDM(report: OnyxEntry): boolean { - return isChatReport(report) && !getChatType(report); + return isChatReport(report) && !getChatType(report) && !isThread(report); } function isSelfDM(report: OnyxEntry): boolean { @@ -1177,7 +1181,7 @@ function hasOnlyTransactionsWithPendingRoutes(iouReportID: string | undefined): * If the report is a thread and has a chat type set, it is a workspace chat. */ function isWorkspaceThread(report: OnyxEntry): boolean { - return isThread(report) && isChatReport(report) && !isDM(report); + return isThread(report) && isChatReport(report) && !!getChatType(report); } /** @@ -1243,7 +1247,6 @@ function isMoneyRequestReport(reportOrID: OnyxEntry | EmptyObject | stri function isOneOnOneChat(report: OnyxEntry): boolean { const participantAccountIDs = report?.participantAccountIDs ?? []; return ( - !isThread(report) && !isChatRoom(report) && !isExpenseRequest(report) && !isMoneyRequestReport(report) && @@ -5248,6 +5251,30 @@ function canBeAutoReimbursed(report: OnyxEntry, policy: OnyxEntry, reportId: string, reportAction: ReportAction): MissingPaymentMethod | undefined { + const isSubmitterOfUnsettledReport = isCurrentUserSubmitter(reportId) && !isSettled(reportId); + if (!isSubmitterOfUnsettledReport || reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED) { + return undefined; + } + const paymentType = reportAction.originalMessage?.paymentType; + if (paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + return isEmpty(userWallet) || userWallet.tierName === CONST.WALLET.TIER_NAME.SILVER ? 'wallet' : undefined; + } + + return !store.hasCreditBankAccount() ? 'bankAccount' : undefined; +} + +/** + * Checks if report chat contains missing payment method + */ +function hasMissingPaymentMethod(userWallet: OnyxEntry, iouReportID: string): boolean { + const reportActions = ReportActionsUtils.getAllReportActions(iouReportID); + return Object.values(reportActions).some((action) => getIndicatedMissingPaymentMethod(userWallet, iouReportID, action) !== undefined); +} + /** * Used from money request actions to decide if we need to build an optimistic money request report. Create a new report if: @@ -5455,6 +5482,7 @@ export { isValidReport, getReportDescriptionText, isReportFieldOfTypeTitle, + hasMissingPaymentMethod, isIOUReportUsingReport, hasUpdatedTotal, isReportFieldDisabled, @@ -5465,6 +5493,7 @@ export { canEditRoomVisibility, canEditPolicyDescription, getPolicyDescriptionText, + getIndicatedMissingPaymentMethod, isJoinRequestInAdminRoom, canAddOrDeleteTransactions, shouldCreateNewMoneyRequestReport, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 40aa4c7247c6..7bf163416054 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -7,7 +7,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, TransactionViolation} from '@src/types/onyx'; import type Beta from '@src/types/onyx/Beta'; -import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type Policy from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; import type {ReportActions} from '@src/types/onyx/ReportAction'; @@ -58,13 +57,6 @@ function compareStringDates(a: string, b: string): 0 | 1 | -1 { return 0; } -function filterDisplayName(displayName: string): string { - if (CONST.REGEX.INVALID_DISPLAY_NAME_ONLY_LHN.test(displayName)) { - return displayName; - } - return displayName.replace(CONST.REGEX.INVALID_DISPLAY_NAME_LHN, '').trim(); -} - /** * @returns An array of reportIDs sorted in the proper order */ @@ -74,27 +66,22 @@ function getOrderedReportIDs( betas: Beta[], policies: Record, priorityMode: ValueOf, - allReportActions: OnyxCollection, + allReportActions: OnyxCollection, transactionViolations: OnyxCollection, currentPolicyID = '', policyMemberAccountIDs: number[] = [], - reportIDsWithErrors: Record = {}, - canUseViolations = false, ): string[] { const isInGSDMode = priorityMode === CONST.PRIORITY_MODE.GSD; const isInDefaultMode = !isInGSDMode; const allReportsDictValues = Object.values(allReports); - const reportIDsWithViolations = new Set(); - // Filter out all the reports that shouldn't be displayed let reportsToDisplay = allReportsDictValues.filter((report) => { const parentReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`; - const parentReportAction = allReportActions?.[parentReportActionsKey]?.[report.parentReportActionID ?? '']; - const doesReportHaveViolations = canUseViolations && !!parentReportAction && ReportUtils.doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction); - if (doesReportHaveViolations) { - reportIDsWithViolations.add(report.reportID); - } + const parentReportActions = allReportActions?.[parentReportActionsKey]; + const parentReportAction = parentReportActions?.find((action) => action && report && action?.reportActionID === report?.parentReportActionID); + const doesReportHaveViolations = + betas.includes(CONST.BETAS.VIOLATIONS) && !!parentReportAction && ReportUtils.doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction); return ReportUtils.shouldReportBeInOptionList({ report, currentReportId: currentReportId ?? '', @@ -116,7 +103,7 @@ function getOrderedReportIDs( } // The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: - // 1. Pinned/GBR/RBR - Always sorted by reportDisplayName + // 1. Pinned/GBR - Always sorted by reportDisplayName // 2. Drafts - Always sorted by reportDisplayName // 3. Non-archived reports and settled IOUs // - Sorted by lastVisibleActionCreated in default (most recent) view mode @@ -124,7 +111,7 @@ function getOrderedReportIDs( // 4. Archived reports // - Sorted by lastVisibleActionCreated in default (most recent) view mode // - Sorted by reportDisplayName in GSD (focus) view mode - const pinnedAndBrickRoadReports: Report[] = []; + const pinnedAndGBRReports: Report[] = []; const draftReports: Report[] = []; const nonArchivedReports: Report[] = []; const archivedReports: Report[] = []; @@ -140,14 +127,12 @@ function getOrderedReportIDs( // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add // the reportDisplayName property to the report object directly. // eslint-disable-next-line no-param-reassign - report.displayName = filterDisplayName(ReportUtils.getReportName(report)); - - const hasRBR = report.reportID in reportIDsWithErrors || reportIDsWithViolations.has(report.reportID); + report.displayName = ReportUtils.getReportName(report); const isPinned = report.isPinned ?? false; const reportAction = ReportActionsUtils.getReportAction(report.parentReportID ?? '', report.parentReportActionID ?? ''); - if (isPinned || hasRBR || ReportUtils.requiresAttentionFromCurrentUser(report, reportAction)) { - pinnedAndBrickRoadReports.push(report); + if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report, reportAction)) { + pinnedAndGBRReports.push(report); } else if (report.hasDraft) { draftReports.push(report); } else if (ReportUtils.isArchivedRoom(report)) { @@ -158,7 +143,7 @@ function getOrderedReportIDs( }); // Sort each group of reports accordingly - pinnedAndBrickRoadReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0)); + pinnedAndGBRReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0)); draftReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0)); if (isInDefaultMode) { @@ -179,7 +164,7 @@ function getOrderedReportIDs( // Now that we have all the reports grouped and sorted, they must be flattened into an array and only return the reportID. // The order the arrays are concatenated in matters and will determine the order that the groups are displayed in the sidebar. - const LHNReports = [...pinnedAndBrickRoadReports, ...draftReports, ...nonArchivedReports, ...archivedReports].map((report) => report.reportID); + const LHNReports = [...pinnedAndGBRReports, ...draftReports, ...nonArchivedReports, ...archivedReports].map((report) => report.reportID); return LHNReports; } @@ -188,19 +173,19 @@ function getOrderedReportIDs( */ function getOptionData({ report, + reportActions, personalDetails, preferredLocale, policy, parentReportAction, - reportErrors, hasViolations, }: { report: OnyxEntry; + reportActions: OnyxEntry; personalDetails: OnyxEntry; preferredLocale: DeepValueOf; policy: OnyxEntry | undefined; parentReportAction: OnyxEntry | undefined; - reportErrors: OnyxCommon.Errors | undefined; hasViolations: boolean; }): ReportUtils.OptionData | undefined { // When a user signs out, Onyx is cleared. Due to the lazy rendering with a virtual list, it's possible for @@ -213,7 +198,7 @@ function getOptionData({ const result: ReportUtils.OptionData = { text: '', alternateText: null, - allReportErrors: reportErrors, + allReportErrors: OptionsListUtils.getAllReportErrors(report, reportActions), brickRoadIndicator: null, tooltipText: null, subtitle: null, @@ -319,7 +304,8 @@ function getOptionData({ const lastActorDisplayName = OptionsListUtils.getLastActorDisplayName(lastActorDetails, hasMultipleParticipants); const lastMessageTextFromReport = OptionsListUtils.getLastMessageTextForReport(report, lastActorDetails, policy); - let lastMessageText = lastMessageTextFromReport; + // We need to remove sms domain in case the last message text has a phone number mention with sms domain. + let lastMessageText = Str.removeSMSDomain(lastMessageTextFromReport); const lastAction = visibleReportActionItems[report.reportID]; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 8a98fe0f2cdc..f8f9ed0e0d47 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -140,11 +140,11 @@ function hasReceipt(transaction: Transaction | undefined | null): boolean { return !!transaction?.receipt?.state || hasEReceipt(transaction); } -function isMerchantMissing(transaction: Transaction) { - if (transaction.modifiedMerchant && transaction.modifiedMerchant !== '') { +function isMerchantMissing(transaction: OnyxEntry) { + if (transaction?.modifiedMerchant && transaction.modifiedMerchant !== '') { return transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; } - const isMerchantEmpty = transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.merchant === ''; + const isMerchantEmpty = transaction?.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction?.merchant === ''; return isMerchantEmpty; } @@ -156,18 +156,18 @@ function isPartialMerchant(merchant: string): boolean { return merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; } -function isAmountMissing(transaction: Transaction) { - return transaction.amount === 0 && (!transaction.modifiedAmount || transaction.modifiedAmount === 0); +function isAmountMissing(transaction: OnyxEntry) { + return transaction?.amount === 0 && (!transaction.modifiedAmount || transaction.modifiedAmount === 0); } -function isCreatedMissing(transaction: Transaction) { - return transaction.created === '' && (!transaction.created || transaction.modifiedCreated === ''); +function isCreatedMissing(transaction: OnyxEntry) { + return transaction?.created === '' && (!transaction.created || transaction.modifiedCreated === ''); } -function areRequiredFieldsEmpty(transaction: Transaction): boolean { +function areRequiredFieldsEmpty(transaction: OnyxEntry): boolean { const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`] ?? null; const isFromExpenseReport = parentReport?.type === CONST.REPORT.TYPE.EXPENSE; - const isSplitPolicyExpenseChat = !!transaction.comment?.splits?.some((participant) => allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`]?.isOwnPolicyExpenseChat); + const isSplitPolicyExpenseChat = !!transaction?.comment?.splits?.some((participant) => allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`]?.isOwnPolicyExpenseChat); const isMerchantRequired = isFromExpenseReport || isSplitPolicyExpenseChat; return (isMerchantRequired && isMerchantMissing(transaction)) || isAmountMissing(transaction) || isCreatedMissing(transaction); } @@ -489,7 +489,7 @@ function hasMissingSmartscanFields(transaction: OnyxEntry): boolean /** * Check if the transaction has a defined route */ -function hasRoute(transaction: Transaction): boolean { +function hasRoute(transaction: OnyxEntry): boolean { return !!transaction?.routes?.route0?.geometry?.coordinates; } diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 6442f2ec0eef..302e4048d0e8 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -222,7 +222,10 @@ function openApp() { function reconnectApp(updateIDFrom: OnyxEntry = 0) { console.debug(`[OnyxUpdates] App reconnecting with updateIDFrom: ${updateIDFrom}`); getPolicyParamsForOpenOrReconnect().then((policyParams) => { - const params: ReconnectAppParams = {...policyParams}; + const params: ReconnectAppParams = { + ...policyParams, + idempotencyKey: `${WRITE_COMMANDS.RECONNECT_APP}`, + }; // When the app reconnects we do a fast "sync" of the LHN and only return chats that have new messages. We achieve this by sending the most recent reportActionID. // we have locally. And then only update the user about chats with messages that have occurred after that reportActionID. diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 85572ebe04a7..48b9948fc1ff 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -26,6 +26,7 @@ import type { OpenPolicyCategoriesPageParams, OpenPolicyDistanceRatesPageParams, OpenPolicyTagsPageParams, + OpenPolicyWorkflowsPageParams, OpenWorkspaceInvitePageParams, OpenWorkspaceMembersPageParams, OpenWorkspaceParams, @@ -34,6 +35,8 @@ import type { SetWorkspaceAutoReportingFrequencyParams, SetWorkspaceAutoReportingMonthlyOffsetParams, SetWorkspaceAutoReportingParams, + SetWorkspacePayerParams, + SetWorkspaceReimbursementParams, UpdateWorkspaceAvatarParams, UpdateWorkspaceCustomUnitAndRateParams, UpdateWorkspaceDescriptionParams, @@ -589,6 +592,106 @@ function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMo API.write(WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE, params, {optimisticData, failureData, successData}); } +function setWorkspacePayer(policyID: string, reimburserEmail: string, reimburserAccountID: number) { + const policy = ReportUtils.getPolicy(policyID); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + reimburserEmail, + reimburserAccountID, + errorFields: {reimburserEmail: null}, + pendingFields: {reimburserEmail: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + errorFields: {reimburserEmail: null}, + pendingFields: {reimburserEmail: null}, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + reimburserEmail: policy.reimburserEmail, + reimburserAccountID: policy.reimburserAccountID, + errorFields: {reimburserEmail: ErrorUtils.getMicroSecondOnyxError('workflowsPayerPage.genericErrorMessage')}, + pendingFields: {reimburserEmail: null}, + }, + }, + ]; + + const params: SetWorkspacePayerParams = {policyID, reimburserEmail}; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_PAYER, params, {optimisticData, failureData, successData}); +} + +function clearWorkspacePayerError(policyID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {reimburserEmail: null}}); +} + +function setWorkspaceReimbursement(policyID: string, reimbursementChoice: ValueOf, reimburserAccountID: number, reimburserEmail: string) { + const policy = ReportUtils.getPolicy(policyID); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + reimbursementChoice, + reimburserAccountID, + reimburserEmail, + errorFields: {reimbursementChoice: null}, + pendingFields: {reimbursementChoice: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + errorFields: {reimbursementChoice: null}, + pendingFields: {reimbursementChoice: null}, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + reimbursementChoice: policy.reimbursementChoice, + reimburserAccountID: policy.reimburserAccountID, + reimburserEmail: policy.reimburserEmail, + errorFields: {reimbursementChoice: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, + pendingFields: {reimbursementChoice: null}, + }, + }, + ]; + + const params: SetWorkspaceReimbursementParams = {policyID, reimbursementChoice}; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_REIMBURSEMENT, params, {optimisticData, failureData, successData}); +} + +function clearWorkspaceReimbursementErrors(policyID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {reimbursementChoice: null}}); +} + /** * Build optimistic data for removing users from the announcement room */ @@ -1887,6 +1990,50 @@ function openWorkspaceReimburseView(policyID: string) { API.read(READ_COMMANDS.OPEN_WORKSPACE_REIMBURSE_VIEW, params, {successData, failureData}); } +function openPolicyWorkflowsPage(policyID: string) { + if (!policyID) { + Log.warn('openPolicyWorkflowsPage invalid params', {policyID}); + return; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM + key: `${ONYXKEYS.REIMBURSEMENT_ACCOUNT}${policyID}`, + value: { + isLoading: true, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM + key: `${ONYXKEYS.REIMBURSEMENT_ACCOUNT}${policyID}`, + value: { + isLoading: false, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM + key: `${ONYXKEYS.REIMBURSEMENT_ACCOUNT}${policyID}`, + value: { + isLoading: false, + }, + }, + ], + }; + + const params: OpenPolicyWorkflowsPageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_WORKFLOWS_PAGE, params, onyxData); +} + function setPolicyIDForReimburseView(policyID: string) { Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {policyID, rate: null, unit: null}); } @@ -2599,6 +2746,70 @@ function createPolicyCategory(policyID: string, categoryName: string) { API.write(WRITE_COMMANDS.CREATE_WORKSPACE_CATEGORIES, parameters, onyxData); } +function createPolicyTag(policyID: string, tagName: string) { + const tagListName = Object.keys(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})[0]; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + name: tagName, + enabled: false, + errors: null, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + errors: null, + pendingAction: null, + }, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + errors: ErrorUtils.getMicroSecondOnyxError('workspace.tags.genericFailureMessage'), + pendingAction: null, + }, + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + tags: JSON.stringify([{name: tagName}]), + }; + + API.write(WRITE_COMMANDS.CREATE_POLICY_TAG, parameters, onyxData); +} + function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolean) { const onyxData: OnyxData = { optimisticData: [ @@ -3292,6 +3503,10 @@ export { declineJoinRequest, createPolicyCategory, clearCategoryErrors, + setWorkspacePayer, + clearWorkspacePayerError, + setWorkspaceReimbursement, + openPolicyWorkflowsPage, setPolicyRequiresTag, renamePolicyTaglist, enablePolicyCategories, @@ -3302,4 +3517,6 @@ export { enablePolicyTaxes, enablePolicyWorkflows, openPolicyDistanceRatesPage, + createPolicyTag, + clearWorkspaceReimbursementErrors, }; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index b677b5369b68..5c20536e5234 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2229,10 +2229,10 @@ function toggleEmojiReaction( addEmojiReaction(originalReportID, reportAction.reportActionID, emoji, skinTone); } -function openReportFromDeepLink(url: string, isAuthenticated: boolean) { +function openReportFromDeepLink(url: string) { const reportID = ReportUtils.getReportIDFromLink(url); - if (reportID && !isAuthenticated) { + if (reportID && !Session.hasAuthToken()) { // Call the OpenReport command to check in the server if it's a public room. If so, we'll open it as an anonymous user openReport(reportID, [], {}, '0', true); diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 619281ac7ecf..2c2baee9b96e 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -184,7 +184,14 @@ function hasStashedSession(): boolean { return Boolean(stashedSession.authToken && stashedCredentials.autoGeneratedLogin && stashedCredentials.autoGeneratedLogin !== ''); } -function signOutAndRedirectToSignIn(shouldReplaceCurrentScreen?: boolean, shouldStashSession?: boolean) { +/** + * Checks if the user has authToken + */ +function hasAuthToken(): boolean { + return !!session.authToken; +} + +function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean) { Log.info('Redirecting to Sign In because signOut() was called'); hideContextMenu(false); if (!isAnonymousUser()) { @@ -233,11 +240,10 @@ function signOutAndRedirectToSignIn(shouldReplaceCurrentScreen?: boolean, should if (Navigation.isActiveRoute(ROUTES.SIGN_IN_MODAL)) { return; } - if (shouldReplaceCurrentScreen) { - Navigation.navigate(ROUTES.SIGN_IN_MODAL, CONST.NAVIGATION.TYPE.UP); - } else { - Navigation.navigate(ROUTES.SIGN_IN_MODAL); + if (shouldResetToHome) { + Navigation.resetToHome(); } + Navigation.navigate(ROUTES.SIGN_IN_MODAL); Linking.getInitialURL().then((url) => { const reportID = ReportUtils.getReportIDFromLink(url); if (reportID) { @@ -987,6 +993,7 @@ export { toggleTwoFactorAuth, validateTwoFactorAuth, waitForUserSignIn, + hasAuthToken, canAnonymousUserAccessRoute, signInWithSupportAuthToken, isSupportAuthToken, diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 3e00d8084825..48ab7cce9186 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -689,8 +689,8 @@ function clearOutTaskInfoAndNavigate(reportID: string) { /** * Get the assignee data */ -function getAssignee(assigneeAccountID: number, personalDetails: OnyxTypes.PersonalDetailsList): Assignee { - const details = personalDetails[assigneeAccountID]; +function getAssignee(assigneeAccountID: number, personalDetails: OnyxEntry): Assignee { + const details = personalDetails?.[assigneeAccountID]; if (!details) { return { @@ -710,7 +710,7 @@ function getAssignee(assigneeAccountID: number, personalDetails: OnyxTypes.Perso /** * Get the share destination data * */ -function getShareDestination(reportID: string, reports: OnyxCollection, personalDetails: OnyxTypes.PersonalDetailsList): ShareDestination { +function getShareDestination(reportID: string, reports: OnyxCollection, personalDetails: OnyxEntry): ShareDestination { const report = reports?.[`report_${reportID}`] ?? null; const participantAccountIDs = report?.participantAccountIDs ?? []; @@ -721,8 +721,8 @@ function getShareDestination(reportID: string, reports: OnyxCollection PolicyUtils.isPolicyMember(report?.policyID ?? '', policies), [report?.policyID, policies]); const shouldUseFullTitle = useMemo(() => ReportUtils.shouldUseFullTitleToDisplay(report), [report]); const isChatRoom = useMemo(() => ReportUtils.isChatRoom(report), [report]); - const isThread = useMemo(() => ReportUtils.isChatThread(report), [report]); const isUserCreatedPolicyRoom = useMemo(() => ReportUtils.isUserCreatedPolicyRoom(report), [report]); + const isDefaultRoom = useMemo(() => ReportUtils.isDefaultRoom(report), [report]); + const isChatThread = useMemo(() => ReportUtils.isChatThread(report), [report]); const isArchivedRoom = useMemo(() => ReportUtils.isArchivedRoom(report), [report]); const isMoneyRequestReport = useMemo(() => ReportUtils.isMoneyRequestReport(report), [report]); const canEditReportDescription = useMemo(() => ReportUtils.canEditReportDescription(report, policy), [report, policy]); @@ -117,9 +118,15 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD } // The Members page is only shown when: + // - The report is a thread in a chat report // - The report is not a user created room with participants to show i.e. DM, Group Chat, etc // - The report is a user created room and the room and the current user is a workspace member i.e. non-workspace members should not see this option. - if ((!isUserCreatedPolicyRoom && participants.length) || (isUserCreatedPolicyRoom && isPolicyMember)) { + if ( + ((isDefaultRoom && isChatThread && isPolicyMember) || + (!isUserCreatedPolicyRoom && participants.length) || + (isUserCreatedPolicyRoom && (isPolicyMember || (isChatThread && !ReportUtils.isPublicRoom(report))))) && + !ReportUtils.isConciergeChatReport(report) + ) { items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.MEMBERS, translationKey: 'common.members', @@ -127,14 +134,17 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD subtitle: participants.length, isAnonymousAction: false, action: () => { - if (isUserCreatedPolicyRoom && !report?.parentReportID) { + if (isUserCreatedPolicyRoom || isChatThread) { Navigation.navigate(ROUTES.ROOM_MEMBERS.getRoute(report?.reportID ?? '')); } else { Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(report?.reportID ?? '')); } }, }); - } else if (isUserCreatedPolicyRoom && (!participants.length || !isPolicyMember) && !report?.parentReportID) { + } else if ( + (isUserCreatedPolicyRoom && (!participants.length || !isPolicyMember)) || + ((isDefaultRoom || ReportUtils.isPolicyExpenseChat(report)) && isChatThread && !isPolicyMember) + ) { items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.INVITE, translationKey: 'common.invite', @@ -157,7 +167,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD }); // Prevent displaying private notes option for threads and task reports - if (!isThread && !isMoneyRequestReport && !ReportUtils.isTaskReport(report)) { + if (!isChatThread && !isMoneyRequestReport && !ReportUtils.isTaskReport(report)) { items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.PRIVATE_NOTES, translationKey: 'privateNotes.title', @@ -169,7 +179,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD } return items; - }, [isArchivedRoom, participants.length, isThread, isMoneyRequestReport, report, isGroupDMChat, isPolicyMember, isUserCreatedPolicyRoom, session, isSelfDM]); + }, [isArchivedRoom, participants.length, isChatThread, isMoneyRequestReport, report, isGroupDMChat, isPolicyMember, isUserCreatedPolicyRoom, session, isSelfDM, isDefaultRoom]); const displayNamesWithTooltips = useMemo(() => { const hasMultipleParticipants = participants.length > 1; @@ -217,8 +227,8 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD fullTitle={ReportUtils.getReportName(report)} displayNamesWithTooltips={displayNamesWithTooltips} tooltipEnabled - numberOfLines={isChatRoom && !isThread ? 0 : 1} - textStyles={[styles.textHeadline, styles.textAlignCenter, isChatRoom && !isThread ? undefined : styles.pre]} + numberOfLines={isChatRoom && !isChatThread ? 0 : 1} + textStyles={[styles.textHeadline, styles.textAlignCenter, isChatRoom && !isChatThread ? undefined : styles.pre]} shouldUseFullTitle={shouldUseFullTitle} /> diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index 82402d62a074..89d20287f66c 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -224,7 +224,9 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { testID={RoomMembersPage.displayName} > { Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); diff --git a/src/pages/WorkspaceSwitcherPage.tsx b/src/pages/WorkspaceSwitcherPage.tsx index 3d46ede8b31e..2eb5ecaf373f 100644 --- a/src/pages/WorkspaceSwitcherPage.tsx +++ b/src/pages/WorkspaceSwitcherPage.tsx @@ -18,6 +18,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -102,7 +103,7 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) { return; } - const {policyID, isPolicyAdmin} = option; + const {policyID} = option; if (policyID) { setSelectedOption(option); @@ -112,7 +113,7 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) { setActiveWorkspaceID(policyID); Navigation.goBack(); if (policyID !== activeWorkspaceID) { - Navigation.navigateWithSwitchPolicyID({policyID, isPolicyAdmin}); + Navigation.navigateWithSwitchPolicyID({policyID}); } }, [activeWorkspaceID, setActiveWorkspaceID], @@ -219,7 +220,7 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) { role={CONST.ROLE.BUTTON} onPress={() => { Navigation.goBack(); - App.createWorkspaceWithPolicyDraftAndNavigateToIt(); + interceptAnonymousUser(() => App.createWorkspaceWithPolicyDraftAndNavigateToIt()); }} > {({hovered}) => ( diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index d4221c324ade..ab09047ede68 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -430,7 +430,7 @@ function ReportScreen({ }, [report, didSubscribeToReportLeavingEvents, reportID]); const onListLayout = useCallback((event: LayoutChangeEvent) => { - setListHeight((prev) => event.nativeEvent.layout.height ?? prev); + setListHeight((prev) => event.nativeEvent?.layout?.height ?? prev); if (!markReadyForHydration) { return; } diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx index 931b87704ce5..ea3828c762c0 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -30,7 +30,6 @@ function extractPointerEvent(event: GestureResponderEvent | MouseEvent): MouseEv return event; } -// eslint-disable-next-line @typescript-eslint/naming-convention function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef) { const {translate} = useLocalize(); const reportIDRef = useRef('0'); diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index a9aba936f807..7216f4238621 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -53,7 +53,6 @@ import {ReactionListContext} from '@pages/home/ReportScreenContext'; import * as BankAccounts from '@userActions/BankAccounts'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import * as Policy from '@userActions/Policy'; -import * as store from '@userActions/ReimbursementAccount/store'; import * as Report from '@userActions/Report'; import * as ReportActions from '@userActions/ReportActions'; import * as Session from '@userActions/Session'; @@ -470,17 +469,13 @@ function ReportActionItem({ const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails[report.ownerAccountID ?? -1]); const paymentType = action.originalMessage.paymentType ?? ''; - const isSubmitterOfUnsettledReport = ReportUtils.isCurrentUserSubmitter(report.reportID) && !ReportUtils.isSettled(report.reportID); - const shouldShowAddCreditBankAccountButton = isSubmitterOfUnsettledReport && !store.hasCreditBankAccount() && paymentType !== CONST.IOU.PAYMENT_TYPE.EXPENSIFY; - const shouldShowEnableWalletButton = - isSubmitterOfUnsettledReport && (isEmptyObject(userWallet) || userWallet?.tierName === CONST.WALLET.TIER_NAME.SILVER) && paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY; - + const missingPaymentMethod = ReportUtils.getIndicatedMissingPaymentMethod(userWallet, report.reportID, action); children = ( <> - {shouldShowAddCreditBankAccountButton && ( + {missingPaymentMethod === 'bankAccount' && (