diff --git a/README.md b/README.md index b69786d64f13..24fa343f0d45 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ * [Debugging](#debugging) * [App Structure and Conventions](#app-structure-and-conventions) * [Philosophy](#Philosophy) +* [Security](#Security) * [Internationalization](#Internationalization) * [Deploying](#deploying) @@ -395,6 +396,117 @@ This application is built with the following principles. ---- +# Security +Updated rules for managing members across all types of chats in New Expensify. + +- **Nobody can leave or be removed from something they were automatically added to. For example:** + + - DM members can't leave or be removed from their DMs + - Members can't leave or be removed from their own workspace chats + - Admins can't leave or be removed from workspace chats + - Members can't leave or be removed from the #announce room + - Admins can't leave or be removed from #admins + - Domain members can't leave or be removed from their domain chat + - Report submitters can't leave or be removed from their reports + - Report managers can't leave or be removed from their reports + - Group owners cannot be removed from their groups - they need to transfer ownership first +- **Excepting the above, admins can remove anyone. For example:** + - Group admins can remove other group admins, as well as group members + - Workspace admins can remove other workspace admins, as well as workspace members, and invited guests +- **Excepting the above, members can remove guests. For example:** + - Workspace members can remove non-workspace guests. +- **Excepting the above, anybody can remove themselves from any object** + +1. ### DM + | | Member + | :---: | :---: + | **Invite** | ❌ + | **Remove** | ❌ + | **Leave** | ❌ + | **Can be removed** | ❌ +- DM always has two participants. None of the participant can leave or be removed from the DM. Also no additional member can be invited to the chat. + +2. ### Workspace + 1. #### Workspace + | | Creator | Member(Employee/User) | Admin | Auditor? + | :---: | :---: | :---: | :---: | :---: + | **Invite** | ✅ | ❌ | ✅ | ❌ + | **Remove** | ✅ | ❌ | ✅ | ❌ + | **Leave** | ❌ | ✅ | ❌ | ✅ + | **Can be removed** | ❌ | ✅ | ✅ | ✅ + + - Creator can't leave or be removed from their own workspace + - Admins can't leave from the workspace + - Admins can remove other workspace admins, as well as workspace members, and invited guests + - Creator can remove other workspace admins, as well as workspace members, and invited guests + - Members and Auditors cannot invite or remove anyone from the workspace + + 2. #### Workspace #announce room + | | Member(Employee/User) | Admin | Auditor? + | :---: | :---: | :---: | :---: + | **Invite** | ❌ | ❌ | ❌ + | **Remove** | ❌ | ❌ | ❌ + | **Leave** | ❌ | ❌ | ❌ + | **Can be removed** | ❌ | ❌ | ❌ | + + - No one can leave or be removed from the #announce room + + 3. #### Workspace #admin room + | | Admin | + | :---: | :---: + | **Invite** | ❌ + | **Remove** | ❌ + | **Leave** | ❌ + | **Can be removed** | ❌ + + - Admins can't leave or be removed from #admins + + 4. #### Workspace rooms + | | Creator | Member | Guest(outside of the workspace) + | :---: | :---: | :---: | :---: + | **Invite** | ✅ | ✅ | ✅ + | **Remove** | ✅ | ✅ | ❌ + | **Leave** | ✅ | ✅ | ✅ + | **Can be removed** | ✅ | ✅ | ✅ + + - Everyone can be removed/can leave from the room including creator + - Guests are not able to remove anyone from the room + + 4. #### Workspace chats + | | Admin | Member(default) | Member(invited) + | :---: | :---: | :---: | :---: + | **Invite** | ✅ | ✅ | ❌ + | **Remove** | ✅ | ✅ | ❌ + | **Leave** | ❌ | ❌ | ✅ + | **Can be removed** | ❌ | ❌ | ✅ + + - Admins are not able to leave/be removed from the workspace chat + - Default members(automatically invited) are not able to leave/be removed from the workspace chat + - Invited members(invited by members) are not able to invite or remove from the workspace chat + - Invited members(invited by members) are able to leave the workspace chat + - Default members and admins are able to remove invited members + +3. ### Domain chat + | | Member + | :---: | :---: + | **Remove** | ❌ + | **Leave** | ❌ + | **Can be removed** | ❌ + +- Domain members can't leave or be removed from their domain chat + +4. ### Reports + | | Submitter | Manager + | :---: | :---: | :---: + | **Remove** | ❌ | ❌ + | **Leave** | ❌ | ❌ + | **Can be removed** | ❌ | ❌ + +- Report submitters can't leave or be removed from their reports (eg, if they are the report.accountID) +- Report managers can't leave or be removed from their reports (eg, if they are the report.managerID) + +---- + # Internationalization This application is built with Internationalization (I18n) / Localization (L10n) support, so it's important to always localize the following types of data when presented to the user (even accessibility texts that are not rendered): diff --git a/__mocks__/@react-native-async-storage/async-storage.js b/__mocks__/@react-native-async-storage/async-storage.js deleted file mode 100644 index 1051fa919c94..000000000000 --- a/__mocks__/@react-native-async-storage/async-storage.js +++ /dev/null @@ -1 +0,0 @@ -export {default} from '@react-native-async-storage/async-storage/jest/async-storage-mock'; diff --git a/__mocks__/@react-native-clipboard/clipboard.js b/__mocks__/@react-native-clipboard/clipboard.js new file mode 100644 index 000000000000..e56e290c3cc9 --- /dev/null +++ b/__mocks__/@react-native-clipboard/clipboard.js @@ -0,0 +1,3 @@ +import MockClipboard from '@react-native-clipboard/clipboard/jest/clipboard-mock'; + +export default MockClipboard; diff --git a/__mocks__/console.js b/__mocks__/console.js deleted file mode 100644 index f064a9e5a746..000000000000 --- a/__mocks__/console.js +++ /dev/null @@ -1,22 +0,0 @@ -import _ from 'underscore'; - -function format(entry) { - if (typeof entry === 'object') { - try { - return JSON.stringify(entry); - // eslint-disable-next-line no-empty - } catch (e) {} - } - - return entry; -} - -function log(...msgs) { - process.stdout.write(`${_.map(msgs, format).join(' ')}\n`); -} - -module.exports = { - log, - warn: log, - error: log, -}; diff --git a/__mocks__/fileMock.js b/__mocks__/fileMock.js deleted file mode 100644 index 86059f362924..000000000000 --- a/__mocks__/fileMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = 'test-file-stub'; diff --git a/__mocks__/fileMock.ts b/__mocks__/fileMock.ts new file mode 100644 index 000000000000..3592aaaaf26b --- /dev/null +++ b/__mocks__/fileMock.ts @@ -0,0 +1,3 @@ +const fileMock = 'test-file-stub'; + +export default fileMock; diff --git a/__mocks__/react-native-config.js b/__mocks__/react-native-config.js deleted file mode 100644 index 7c3900efa21e..000000000000 --- a/__mocks__/react-native-config.js +++ /dev/null @@ -1,6 +0,0 @@ -const path = require('path'); -const dotenv = require('dotenv'); - -const env = dotenv.config({path: path.resolve('./.env.example')}).parsed; - -export default env; diff --git a/__mocks__/react-native-config.ts b/__mocks__/react-native-config.ts new file mode 100644 index 000000000000..129d7c97ed43 --- /dev/null +++ b/__mocks__/react-native-config.ts @@ -0,0 +1,8 @@ +import dotenv from 'dotenv'; +import path from 'path'; + +type ReactNativeConfigMock = dotenv.DotenvParseOutput | undefined; + +const reactNativeConfigMock: ReactNativeConfigMock = dotenv.config({path: path.resolve('./.env.example')}).parsed; + +export default reactNativeConfigMock; diff --git a/__mocks__/react-native-document-picker.js b/__mocks__/react-native-document-picker.js new file mode 100644 index 000000000000..8cba2bc1eba4 --- /dev/null +++ b/__mocks__/react-native-document-picker.js @@ -0,0 +1,23 @@ +export default { + getConstants: jest.fn(), + pick: jest.fn(), + releaseSecureAccess: jest.fn(), + pickDirectory: jest.fn(), + + types: Object.freeze({ + allFiles: 'public.item', + audio: 'public.audio', + csv: 'public.comma-separated-values-text', + doc: 'com.microsoft.word.doc', + docx: 'org.openxmlformats.wordprocessingml.document', + images: 'public.image', + pdf: 'com.adobe.pdf', + plainText: 'public.plain-text', + ppt: 'com.microsoft.powerpoint.ppt', + pptx: 'org.openxmlformats.presentationml.presentation', + video: 'public.movie', + xls: 'com.microsoft.excel.xls', + xlsx: 'org.openxmlformats.spreadsheetml.sheet', + zip: 'public.zip-archive', + }), +}; diff --git a/__mocks__/react-native-image-picker.js b/__mocks__/react-native-image-picker.js deleted file mode 100644 index 7e4c29fa79ac..000000000000 --- a/__mocks__/react-native-image-picker.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - showImagePicker: jest.fn(), - launchCamera: jest.fn(), - launchImageLibrary: jest.fn(), -}; diff --git a/__mocks__/react-native-key-command.js b/__mocks__/react-native-key-command.js deleted file mode 100644 index 5bd02dfd121d..000000000000 --- a/__mocks__/react-native-key-command.js +++ /dev/null @@ -1,7 +0,0 @@ -const registerKeyCommands = () => {}; -const unregisterKeyCommands = () => {}; -const constants = {}; -const eventEmitter = () => {}; -const addListener = () => {}; - -export {registerKeyCommands, unregisterKeyCommands, constants, eventEmitter, addListener}; diff --git a/__mocks__/react-native-key-command.ts b/__mocks__/react-native-key-command.ts new file mode 100644 index 000000000000..288b7847684d --- /dev/null +++ b/__mocks__/react-native-key-command.ts @@ -0,0 +1,9 @@ +import type {addListener as _addListener, constants as _constants} from 'react-native-key-command'; + +const registerKeyCommands = () => {}; +const unregisterKeyCommands = () => {}; +const constants: Partial = {}; +const eventEmitter = () => {}; +const addListener: typeof _addListener = () => () => {}; + +export {addListener, constants, eventEmitter, registerKeyCommands, unregisterKeyCommands}; diff --git a/android/app/build.gradle b/android/app/build.gradle index ed7b2f568b93..dc72b8825ae6 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 1001043907 - versionName "1.4.39-7" + versionCode 1001044004 + versionName "1.4.40-4" } flavorDimensions "default" diff --git a/android/app/src/main/res/raw/attention.mp3 b/android/app/src/main/res/raw/attention.mp3 new file mode 100644 index 000000000000..854be9cbcf07 Binary files /dev/null and b/android/app/src/main/res/raw/attention.mp3 differ diff --git a/android/app/src/main/res/raw/done.mp3 b/android/app/src/main/res/raw/done.mp3 new file mode 100644 index 000000000000..23d6a2b6bdb5 Binary files /dev/null and b/android/app/src/main/res/raw/done.mp3 differ diff --git a/android/app/src/main/res/raw/receive.mp3 b/android/app/src/main/res/raw/receive.mp3 new file mode 100644 index 000000000000..28f03052a14b Binary files /dev/null and b/android/app/src/main/res/raw/receive.mp3 differ diff --git a/android/app/src/main/res/raw/success.mp3 b/android/app/src/main/res/raw/success.mp3 new file mode 100644 index 000000000000..bd1af6526e40 Binary files /dev/null and b/android/app/src/main/res/raw/success.mp3 differ diff --git a/assets/css/fonts.css b/assets/css/fonts.css index 078cec114c31..e93fb6d806da 100644 --- a/assets/css/fonts.css +++ b/assets/css/fonts.css @@ -55,8 +55,8 @@ } @font-face { - font-family: Windows Segoe UI Emoji; - src: url('/fonts/seguiemj.ttf'); + font-family: Noto Color Emoji; + src: url('/fonts/NotoColorEmoji-Regular.woff2') format('woff2'), url('/fonts/NotoColorEmoji-Regular.woff') format('woff'); } * { diff --git a/assets/fonts/web/NotoColorEmoji-Regular.woff b/assets/fonts/web/NotoColorEmoji-Regular.woff new file mode 100644 index 000000000000..ba2eccd5eac9 Binary files /dev/null and b/assets/fonts/web/NotoColorEmoji-Regular.woff differ diff --git a/assets/fonts/web/NotoColorEmoji-Regular.woff2 b/assets/fonts/web/NotoColorEmoji-Regular.woff2 new file mode 100644 index 000000000000..7cb695dc176b Binary files /dev/null and b/assets/fonts/web/NotoColorEmoji-Regular.woff2 differ diff --git a/assets/images/simple-illustrations/simple-illustration__receipt-wrangler.svg b/assets/images/simple-illustrations/simple-illustration__receipt-wrangler.svg new file mode 100644 index 000000000000..f6bca6a344ea --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__receipt-wrangler.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/sounds/attention.mp3 b/assets/sounds/attention.mp3 new file mode 100644 index 000000000000..854be9cbcf07 Binary files /dev/null and b/assets/sounds/attention.mp3 differ diff --git a/assets/sounds/done.mp3 b/assets/sounds/done.mp3 new file mode 100644 index 000000000000..23d6a2b6bdb5 Binary files /dev/null and b/assets/sounds/done.mp3 differ diff --git a/assets/sounds/receive.mp3 b/assets/sounds/receive.mp3 new file mode 100644 index 000000000000..28f03052a14b Binary files /dev/null and b/assets/sounds/receive.mp3 differ diff --git a/assets/sounds/success.mp3 b/assets/sounds/success.mp3 new file mode 100644 index 000000000000..bd1af6526e40 Binary files /dev/null and b/assets/sounds/success.mp3 differ diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 9b1a28382fa2..8ad574d3b2e0 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -58,7 +58,9 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ publicPath: '/', }, stats: { - warningsFilter: [], + // We can ignore the "module not installed" warning from lottie-react-native + // because we are not using the library for JSON format of Lottie animations. + warningsFilter: ['./node_modules/lottie-react-native/lib/module/LottieView/index.web.js'], }, plugins: [ new CleanWebpackPlugin(), @@ -97,6 +99,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ {from: 'web/manifest.json'}, {from: 'assets/css', to: 'css'}, {from: 'assets/fonts/web', to: 'fonts'}, + {from: 'assets/sounds', to: 'sounds'}, {from: 'node_modules/react-pdf/dist/esm/Page/AnnotationLayer.css', to: 'css/AnnotationLayer.css'}, {from: 'node_modules/react-pdf/dist/esm/Page/TextLayer.css', to: 'css/TextLayer.css'}, {from: 'assets/images/shadow.png', to: 'images/shadow.png'}, @@ -200,7 +203,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ alias: { 'react-native-config': 'react-web-config', 'react-native$': 'react-native-web', - + 'react-native-sound': 'react-native-web-sound', // Module alias for web & desktop // https://webpack.js.org/configuration/resolve/#resolvealias '@assets': path.resolve(__dirname, '../../assets'), @@ -239,6 +242,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ 'process/browser': require.resolve('process/browser'), }, }, + optimization: { runtimeChunk: 'single', splitChunks: { diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 268c706bedef..bdc1b8e4bb1e 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-serve": "^1.2.0", + "electron-serve": "^1.3.0", "electron-updater": "^6.1.7", "node-machine-id": "^1.1.12" } @@ -145,9 +145,9 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "node_modules/electron-serve": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.2.0.tgz", - "integrity": "sha512-zJG3wisMrDn2G/gnjrhyB074COvly1FnS0U7Edm8bfXLB8MYX7UtwR9/y2LkFreYjzQHm9nEbAfgCmF+9M9LHQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.3.0.tgz", + "integrity": "sha512-OEC/48ZBJxR6XNSZtCl4cKPyQ1lvsu8yp8GdCplMWwGS1eEyMcEmzML5BRs/io/RLDnpgyf+7rSL+X6ICifRIg==", "engines": { "node": ">=12" }, @@ -536,9 +536,9 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "electron-serve": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.2.0.tgz", - "integrity": "sha512-zJG3wisMrDn2G/gnjrhyB074COvly1FnS0U7Edm8bfXLB8MYX7UtwR9/y2LkFreYjzQHm9nEbAfgCmF+9M9LHQ==" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.3.0.tgz", + "integrity": "sha512-OEC/48ZBJxR6XNSZtCl4cKPyQ1lvsu8yp8GdCplMWwGS1eEyMcEmzML5BRs/io/RLDnpgyf+7rSL+X6ICifRIg==" }, "electron-updater": { "version": "6.1.7", diff --git a/desktop/package.json b/desktop/package.json index 563a45851eb2..da1610e49b23 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,7 +6,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-serve": "^1.2.0", + "electron-serve": "^1.3.0", "electron-updater": "^6.1.7", "node-machine-id": "^1.1.12" }, diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md index f7605fd93b2e..9e35e51ec973 100644 --- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md +++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md @@ -38,7 +38,7 @@ To create your Control Workspace: 2. Select *Group* and click the button that says *New Workspace* 3. Click *Select* under Control -The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your workspace's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s workspace settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. That’s a 75% discount off the unbundled price point if you choose to use a different Corporate Card (or no) provider. +The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your workspace's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s workspace settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Visa® Commercial Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. Adopting the Expensify Card with an Annual Subscription gives a 75% discount off the unbundled price. ## Step 3: Connect your accounting system As a small to medium-sized business, it's important to maintain proper spend management to ensure the success and stability of your organization. This requires paying close attention to your expenses, streamlining your financial processes, and making sure that your financial information is accurate, compliant, and transparent. Include best practices such as: @@ -143,7 +143,7 @@ Let’s walk through the process of linking your business bank account: 4. Once that’s done, we’ll collect all of the necessary information on your business, such as your legal business name and address 5. We’ll then collect your personal information, and a photo ID to confirm your identity -You only need to do this once: you are fully set up for not only reimbursing expense reports, but issuing Expensify Cards, collecting customer invoice payments online (if applicable), as well as paying supplier bills online. +You only need to do this once: you are fully set up for not only reimbursing expense reports, but granting Expensify Cards, collecting customer invoice payments online (if applicable), as well as paying supplier bills online. ## Step 9: Invite employees and set an approval workflow *Select an Approval Mode* @@ -194,8 +194,8 @@ As mentioned above, we’ll be able to pull in transactions as they post (daily) ### If you don't have a corporate card, use the Expensify Card (US only) Expensify provides a corporate card with the following features: -- Up to 2% cash back -- [SmartLimits](https://help.expensify.com/articles/expensify-classic/expensify-card/Card-Settings) to control what each individual cardholder can spend +- Up to 2% cash back (_Applies to USD purchases only._) +- [SmartLimits]([https://help.expensify.com/articles/expensify-classic/expensify-card/Card-Settings](https://community.expensify.com/discussion/4851/deep-dive-what-are-smart-limits?utm_source=community-search&utm_medium=organic-search&utm_term=smart+limits)) to control what each individual cardholder can spend - A stable, unbreakable real-time connection (third-party bank feeds can run into connectivity issues) - Receipt compliance - informing notifications (e.g. add a receipt!) for users *as soon as the card is swiped* - Unlimited Virtual Cards - single-purpose cards with a fixed or monthly limit for specific company purchases @@ -225,7 +225,7 @@ As a small business, managing bills and invoices can be a complex and time-consu Here are some of the key benefits of using Expensify for bill payments and invoicing: - Flexible payment options: Expensify allows you to pay your bills via ACH, credit card, or check, so you can choose the option that works best for you (US businesses only). -- Free, No Fees: The bill pay and invoicing features come included with every workspace and workspace, so you won't need to pay any additional fees. +- No Cost Feature: The bill pay and invoicing features come included with every workspace and plan. - Integration with your business bank account: With your business bank account verified, you can easily link your finances to receive payment from customers when invoices are paid. Let’s first chat through how Bill Pay works diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 5d3bf1c07985..9b1451b2bf94 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ 059DC4EFD39EF39437E6823D /* libPods-NotificationServiceExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A997AA8204EA3D90907FA80 /* libPods-NotificationServiceExtension.a */; }; + 083353EB2B5AB22A00C603C0 /* attention.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E72B5AB22900C603C0 /* attention.mp3 */; }; + 083353EC2B5AB22A00C603C0 /* done.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E82B5AB22900C603C0 /* done.mp3 */; }; + 083353ED2B5AB22A00C603C0 /* receive.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E92B5AB22900C603C0 /* receive.mp3 */; }; + 083353EE2B5AB22A00C603C0 /* success.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353EA2B5AB22900C603C0 /* success.mp3 */; }; 0C7C65547D7346EB923BE808 /* ExpensifyMono-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = E704648954784DDFBAADF568 /* ExpensifyMono-Regular.otf */; }; 0CDA8E34287DD650004ECBEC /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */; }; 0CDA8E35287DD650004ECBEC /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */; }; @@ -79,6 +83,10 @@ 00E356EE1AD99517003FC87E /* NewExpensifyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NewExpensifyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 076FD9E41E08971BBF51D580 /* libPods-NewExpensify-NewExpensifyTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NewExpensify-NewExpensifyTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 083353E72B5AB22900C603C0 /* attention.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = attention.mp3; path = ../assets/sounds/attention.mp3; sourceTree = ""; }; + 083353E82B5AB22900C603C0 /* done.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = done.mp3; path = ../assets/sounds/done.mp3; sourceTree = ""; }; + 083353E92B5AB22900C603C0 /* receive.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = receive.mp3; path = ../assets/sounds/receive.mp3; sourceTree = ""; }; + 083353EA2B5AB22900C603C0 /* success.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = success.mp3; path = ../assets/sounds/success.mp3; sourceTree = ""; }; 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = NewExpensify/AppDelegate.mm; sourceTree = ""; }; 0CDA8E36287DD6A0004ECBEC /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = NewExpensify/Images.xcassets; sourceTree = ""; }; 0F5BE0CD252686320097D869 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -311,6 +319,10 @@ A9EA265D209D4558995C9BD4 /* Resources */ = { isa = PBXGroup; children = ( + 083353E72B5AB22900C603C0 /* attention.mp3 */, + 083353E82B5AB22900C603C0 /* done.mp3 */, + 083353E92B5AB22900C603C0 /* receive.mp3 */, + 083353EA2B5AB22900C603C0 /* success.mp3 */, 44BF435285B94E5B95F90994 /* ExpensifyNewKansas-Medium.otf */, D2AFB39EC1D44BF9B91D3227 /* ExpensifyNewKansas-MediumItalic.otf */, DCF33E34FFEC48128CDD41D4 /* ExpensifyMono-Bold.otf */, @@ -496,13 +508,17 @@ 0F5BE0CE252686330097D869 /* GoogleService-Info.plist in Resources */, E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */, F0C450EA2705020500FD2970 /* colors.json in Resources */, + 083353EB2B5AB22A00C603C0 /* attention.mp3 in Resources */, 0CDA8E37287DD6A0004ECBEC /* Images.xcassets in Resources */, 70CF6E82262E297300711ADC /* BootSplash.storyboard in Resources */, FF941A8D48F849269AB85C9A /* ExpensifyNewKansas-Medium.otf in Resources */, BDB853621F354EBB84E619C2 /* ExpensifyNewKansas-MediumItalic.otf in Resources */, 26AF3C3540374A9FACB6C19E /* ExpensifyMono-Bold.otf in Resources */, + 083353EE2B5AB22A00C603C0 /* success.mp3 in Resources */, 0C7C65547D7346EB923BE808 /* ExpensifyMono-Regular.otf in Resources */, 2A9F8CDA983746B0B9204209 /* ExpensifyNeue-Bold.otf in Resources */, + 083353EC2B5AB22A00C603C0 /* done.mp3 in Resources */, + 083353ED2B5AB22A00C603C0 /* receive.mp3 in Resources */, ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */, 30581EA8AAFD4FCE88C5D191 /* ExpensifyNeue-Italic.otf in Resources */, 1246A3EF20E54E7A9494C8B9 /* ExpensifyNeue-Regular.otf in Resources */, diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 4f571739ecc7..2c48680bf3bd 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.39 + 1.4.40 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.39.7 + 1.4.40.4 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index bc29fdff60c2..b8b2f7e2383b 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.39 + 1.4.40 CFBundleSignature ???? CFBundleVersion - 1.4.39.7 + 1.4.40.4 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index b54ddc36ddf0..897ee8140144 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.39 + 1.4.40 CFBundleVersion - 1.4.39.7 + 1.4.40.4 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2c05bf4175d8..19a579b846e8 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1166,7 +1166,7 @@ PODS: - react-native-config/App (= 1.4.6) - react-native-config/App (1.4.6): - React-Core - - react-native-document-picker (8.2.1): + - react-native-document-picker (9.1.1): - React-Core - react-native-geolocation (3.0.6): - React-Core @@ -1372,7 +1372,7 @@ PODS: - React-Core - RNCAsyncStorage (1.21.0): - React-Core - - RNCClipboard (1.12.1): + - RNCClipboard (1.13.2): - React-Core - RNCPicker (2.5.1): - React-Core @@ -1438,6 +1438,11 @@ PODS: - glog - RCT-Folly (= 2022.05.16.00) - React-Core + - RNSound (0.11.2): + - React-Core + - RNSound/Core (= 0.11.2) + - RNSound/Core (0.11.2): + - React-Core - RNSVG (14.0.0): - React-Core - SDWebImage (5.17.0): @@ -1580,6 +1585,7 @@ DEPENDENCIES: - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) + - RNSound (from `../node_modules/react-native-sound`) - RNSVG (from `../node_modules/react-native-svg`) - VisionCamera (from `../node_modules/react-native-vision-camera`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -1827,6 +1833,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/react-native-screens" + RNSound: + :path: "../node_modules/react-native-sound" RNSVG: :path: "../node_modules/react-native-svg" VisionCamera: @@ -1916,7 +1924,7 @@ SPEC CHECKSUMS: react-native-blob-util: 30a6c9fd067aadf9177e61a998f2c7efb670598d react-native-cameraroll: 8ffb0af7a5e5de225fd667610e2979fc1f0c2151 react-native-config: 7cd105e71d903104e8919261480858940a6b9c0e - react-native-document-picker: 69ca2094d8780cfc1e7e613894d15290fdc54bba + react-native-document-picker: 3599b238843369026201d2ef466df53f77ae0452 react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903 react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b @@ -1954,7 +1962,7 @@ SPEC CHECKSUMS: ReactCommon: 45b5d4f784e869c44a6f5a8fad5b114ca8f78c53 RNAppleAuthentication: 0571c08da8c327ae2afc0261b48b4a515b0286a6 RNCAsyncStorage: 618d03a5f52fbccb3d7010076bc54712844c18ef - RNCClipboard: d77213bfa269013bf4b857b7a9ca37ee062d8ef1 + RNCClipboard: 60fed4b71560d7bfe40e9d35dea9762b024da86d RNCPicker: 529d564911e93598cc399b56cc0769ce3675f8c8 RNDeviceInfo: 4701f0bf2a06b34654745053db0ce4cb0c53ada7 RNDevMenu: 72807568fe4188bd4c40ce32675d82434b43c45d @@ -1973,6 +1981,7 @@ SPEC CHECKSUMS: RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c RNReanimated: 57f436e7aa3d277fbfed05e003230b43428157c0 RNScreens: b582cb834dc4133307562e930e8fa914b8c04ef2 + RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 RNSVG: 255767813dac22db1ec2062c8b7e7b856d4e5ae6 SDWebImage: 750adf017a315a280c60fde706ab1e552a3ae4e9 SDWebImageAVIFCoder: 8348fef6d0ec69e129c66c9fe4d74fbfbf366112 diff --git a/jest.config.js b/jest.config.js index b347db593d83..95ecc350ed9f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,6 +26,6 @@ module.exports = { setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.js'], cacheDirectory: '/.jest-cache', moduleNameMapper: { - '\\.(lottie)$': '/__mocks__/fileMock.js', + '\\.(lottie)$': '/__mocks__/fileMock.ts', }, }; diff --git a/jest/setup.ts b/jest/setup.ts index 68d904fac5be..55774ff136f1 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -38,3 +38,11 @@ jest.mock('react-native-fs', () => ({ unlink: jest.fn(() => new Promise((res) => res())), CachesDirectoryPath: jest.fn(), })); + +jest.mock('react-native-sound', () => { + class SoundMock { + play = jest.fn(); + } + + return SoundMock; +}); diff --git a/package-lock.json b/package-lock.json index 1cc630ce5dac..f37598a02ae0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.39-7", + "version": "1.4.40-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.39-7", + "version": "1.4.40-4", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -27,7 +27,7 @@ "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "1.21.0", "@react-native-camera-roll/camera-roll": "5.4.0", - "@react-native-clipboard/clipboard": "^1.12.1", + "@react-native-clipboard/clipboard": "^1.13.2", "@react-native-community/geolocation": "^3.0.6", "@react-native-community/netinfo": "11.2.1", "@react-native-firebase/analytics": "^12.3.0", @@ -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#4a61536649cbfe49236a35bc7542b5dfd0767e4a", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7aa1d42600d2f59565c1ff962f691b494ccc2813", "expo": "^50.0.3", "expo-image": "1.10.1", "fbjs": "^3.0.2", @@ -82,7 +82,7 @@ "react-native-config": "^1.4.5", "react-native-dev-menu": "^4.1.1", "react-native-device-info": "^10.3.0", - "react-native-document-picker": "^8.2.1", + "react-native-document-picker": "^9.1.1", "react-native-draggable-flatlist": "^4.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.14.1", @@ -101,7 +101,7 @@ "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", @@ -109,13 +109,15 @@ "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.7.4", "react-native-screens": "3.29.0", - "react-native-svg": "14.0.0", + "react-native-sound": "^0.11.2", + "react-native-svg": "14.1.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", "react-native-vision-camera": "2.16.8", "react-native-web": "^0.19.9", "react-native-web-linear-gradient": "^1.1.2", + "react-native-web-sound": "^0.1.3", "react-native-webview": "13.6.3", "react-pdf": "7.3.3", "react-plaid-link": "3.3.2", @@ -8736,9 +8738,9 @@ } }, "node_modules/@react-native-clipboard/clipboard": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.12.1.tgz", - "integrity": "sha512-+PNk8kflpGte0W1Nz61/Dp8gHTxyuRjkVyRYBawymSIGTDHCC/zOJSbig6kGIkD8MeaGHC2vGYQJyUyCrgVPBQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.13.2.tgz", + "integrity": "sha512-uVM55oEGc6a6ZmSATDeTcMm55A/C1km5X47g0xaoF0Zagv7N/8RGvLceA5L/izPwflIy78t7XQeJUcnGSib0nA==", "peerDependencies": { "react": ">=16.0", "react-native": ">=0.57.0" @@ -31234,8 +31236,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#4a61536649cbfe49236a35bc7542b5dfd0767e4a", - "integrity": "sha512-UOy3btYvKRZ1kS4etLPw6Lgfqx+yiM3GMd340K06YLasn24alKgMOmg2dqSRTApF7RltS2FjOXRddAhzgvJZ3w==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#7aa1d42600d2f59565c1ff962f691b494ccc2813", + "integrity": "sha512-f5zeUthFwmWFiGO1as+ARuYv7TbXVktnKAjoFKhaVVSTZvgbojSnzGClCCh4uwbdl9HDRv7aiXfRN1nzuIFOTg==", "license": "MIT", "dependencies": { "classnames": "2.4.0", @@ -33754,6 +33756,11 @@ "node": ">=10" } }, + "node_modules/howler": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", + "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==" + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", @@ -44903,9 +44910,9 @@ } }, "node_modules/react-native-document-picker": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-8.2.1.tgz", - "integrity": "sha512-luH2hKdq4cUwE651OscyGderLMsCusOsBzw4MBca91CgprlAGVMm1/pDwJDV5t9LIewVK8DIgXGXzgrsusKVhA==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-9.1.1.tgz", + "integrity": "sha512-BW+7DbsILuFThlBm7NUFVUmKKf6Awkcf9R0q8wiCU2DlGGtAKQTt2iHpO5+Dn/7WMPB+rqNv3X1HsmJQ0t5R3g==", "dependencies": { "invariant": "^2.2.4" }, @@ -45202,8 +45209,8 @@ }, "node_modules/react-native-picker-select": { "version": "8.1.0", - "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", - "integrity": "sha512-e8TAWVR4AEw2PFGFxlevCBFr1RwvwTqq1M2w9Yi6xNz+d4SbG6tDIcJDNIqt0gyBqvxlL7BuK0G5BjbiZDLKsg==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", + "integrity": "sha512-iASqj8cXSQY+P3ZhfW1eoVcK0UB+TRTddrNSQ3lmIH0a4lYO3m4XJC+cnoCjjPl/sTzUaShpOnpBfqMueR6UMA==", "license": "MIT", "dependencies": { "lodash.isequal": "^4.5.0" @@ -45343,10 +45350,18 @@ "react-native": "*" } }, + "node_modules/react-native-sound": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.11.2.tgz", + "integrity": "sha512-LmGc8lgOK3qecYMVQpyHvww/C+wgT6sWeMpVbOe4NCRGC2yKd4fo4U0KBUo9PO7AqKESO3I/2GZg1/C0+bwiiA==", + "peerDependencies": { + "react-native": ">=0.8.0" + } + }, "node_modules/react-native-svg": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-14.0.0.tgz", - "integrity": "sha512-17W/gWXRUMS7p7PSHu/WyGkZUc1NzRTGxxXc0VqBLjzKSchyb0EmgsiWf9aKmfC6gmY0wcsmKZcGV41bCcNfBA==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-14.1.0.tgz", + "integrity": "sha512-HeseElmEk+AXGwFZl3h56s0LtYD9HyGdrpg8yd9QM26X+d7kjETrRQ9vCjtxuT5dCZEIQ5uggU1dQhzasnsCWA==", "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3" @@ -45428,6 +45443,17 @@ "react-native-web": "*" } }, + "node_modules/react-native-web-sound": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/react-native-web-sound/-/react-native-web-sound-0.1.3.tgz", + "integrity": "sha512-aUWNZuRFM7aREePiDgsTaBaaKX+aHKF0cDXtfOwFn4fQXkN+w5Ny3HykFrbDMxZagXSav5QjPntcA51lpWnSgg==", + "dependencies": { + "howler": "^2.2.1" + }, + "peerDependencies": { + "react-native-web": "*" + } + }, "node_modules/react-native-web/node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", diff --git a/package.json b/package.json index 5870241897b9..eb64e5b0c7ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.39-7", + "version": "1.4.40-4", "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.", @@ -75,7 +75,7 @@ "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "1.21.0", "@react-native-camera-roll/camera-roll": "5.4.0", - "@react-native-clipboard/clipboard": "^1.12.1", + "@react-native-clipboard/clipboard": "^1.13.2", "@react-native-community/geolocation": "^3.0.6", "@react-native-community/netinfo": "11.2.1", "@react-native-firebase/analytics": "^12.3.0", @@ -100,7 +100,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#4a61536649cbfe49236a35bc7542b5dfd0767e4a", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7aa1d42600d2f59565c1ff962f691b494ccc2813", "expo": "^50.0.3", "expo-image": "1.10.1", "fbjs": "^3.0.2", @@ -130,7 +130,7 @@ "react-native-config": "^1.4.5", "react-native-dev-menu": "^4.1.1", "react-native-device-info": "^10.3.0", - "react-native-document-picker": "^8.2.1", + "react-native-document-picker": "^9.1.1", "react-native-draggable-flatlist": "^4.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.14.1", @@ -149,7 +149,7 @@ "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", @@ -157,13 +157,15 @@ "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.7.4", "react-native-screens": "3.29.0", - "react-native-svg": "14.0.0", + "react-native-sound": "^0.11.2", + "react-native-svg": "14.1.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", "react-native-vision-camera": "2.16.8", "react-native-web": "^0.19.9", "react-native-web-linear-gradient": "^1.1.2", + "react-native-web-sound": "^0.1.3", "react-native-webview": "13.6.3", "react-pdf": "7.3.3", "react-plaid-link": "3.3.2", diff --git a/patches/react-native-sound+0.11.2.patch b/patches/react-native-sound+0.11.2.patch new file mode 100644 index 000000000000..661e39263c43 --- /dev/null +++ b/patches/react-native-sound+0.11.2.patch @@ -0,0 +1,38 @@ +diff --git a/node_modules/react-native-sound/RNSound/RNSound.h b/node_modules/react-native-sound/RNSound/RNSound.h +index 7f5b97b..1a3c840 100644 +--- a/node_modules/react-native-sound/RNSound/RNSound.h ++++ b/node_modules/react-native-sound/RNSound/RNSound.h +@@ -1,17 +1,7 @@ +-#if __has_include() + #import +-#else +-#import "RCTBridgeModule.h" +-#endif +- + #import +- +-#if __has_include() + #import +-#else +-#import "RCTEventEmitter.h" +-#endif + + @interface RNSound : RCTEventEmitter +-@property (nonatomic, weak) NSNumber *_key; ++@property(nonatomic, weak) NSNumber *_key; + @end +diff --git a/node_modules/react-native-sound/RNSound/RNSound.m b/node_modules/react-native-sound/RNSound/RNSound.m +index df3784e..d34ac01 100644 +--- a/node_modules/react-native-sound/RNSound/RNSound.m ++++ b/node_modules/react-native-sound/RNSound/RNSound.m +@@ -1,10 +1,6 @@ + #import "RNSound.h" + +-#if __has_include("RCTUtils.h") +-#import "RCTUtils.h" +-#else + #import +-#endif + + @implementation RNSound { + NSMutableDictionary *_playerPool; diff --git a/patches/react-native-web+0.19.9+003+fix-pointer-events.patch b/patches/react-native-web+0.19.9+003+fix-pointer-events.patch deleted file mode 100644 index a457fbcfe36c..000000000000 --- a/patches/react-native-web+0.19.9+003+fix-pointer-events.patch +++ /dev/null @@ -1,22 +0,0 @@ -diff --git a/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js b/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js -index bdcecc2..63f1364 100644 ---- a/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js -+++ b/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js -@@ -353,7 +353,7 @@ function createAtomicRules(identifier, property, value) { - var _block2 = createDeclarationBlock({ - pointerEvents: 'none' - }); -- rules.push(selector + ">*" + _block2); -+ rules.push(selector + " *" + _block2); - } - } else if (value === 'none' || value === 'box-none') { - finalValue = 'none!important'; -@@ -361,7 +361,7 @@ function createAtomicRules(identifier, property, value) { - var _block3 = createDeclarationBlock({ - pointerEvents: 'auto' - }); -- rules.push(selector + ">*" + _block3); -+ rules.push(selector + " *" + _block3); - } - } - var _block4 = createDeclarationBlock({ diff --git a/src/CONST.ts b/src/CONST.ts index eae4b8ec7a2b..f1b7e74bbde4 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -195,6 +195,71 @@ const CONST = { DOMAIN: '@expensify.sms', }, BANK_ACCOUNT: { + BANK_INFO_STEP: { + INPUT_KEY: { + ROUTING_NUMBER: 'routingNumber', + ACCOUNT_NUMBER: 'accountNumber', + PLAID_MASK: 'plaidMask', + IS_SAVINGS: 'isSavings', + BANK_NAME: 'bankName', + PLAID_ACCOUNT_ID: 'plaidAccountID', + PLAID_ACCESS_TOKEN: 'plaidAccessToken', + }, + }, + PERSONAL_INFO_STEP: { + INPUT_KEY: { + FIRST_NAME: 'firstName', + LAST_NAME: 'lastName', + DOB: 'dob', + SSN_LAST_4: 'ssnLast4', + STREET: 'requestorAddressStreet', + CITY: 'requestorAddressCity', + STATE: 'requestorAddressState', + ZIP_CODE: 'requestorAddressZipCode', + }, + }, + BUSINESS_INFO_STEP: { + INPUT_KEY: { + COMPANY_NAME: 'companyName', + COMPANY_TAX_ID: 'companyTaxID', + COMPANY_WEBSITE: 'website', + COMPANY_PHONE: 'companyPhone', + STREET: 'addressStreet', + CITY: 'addressCity', + STATE: 'addressState', + ZIP_CODE: 'addressZipCode', + INCORPORATION_TYPE: 'incorporationType', + INCORPORATION_DATE: 'incorporationDate', + INCORPORATION_STATE: 'incorporationState', + HAS_NO_CONNECTION_TO_CANNABIS: 'hasNoConnectionToCannabis', + }, + }, + BENEFICIAL_OWNER_INFO_STEP: { + SUBSTEP: { + IS_USER_UBO: 1, + IS_ANYONE_ELSE_UBO: 2, + UBO_DETAILS_FORM: 3, + ARE_THERE_MORE_UBOS: 4, + UBOS_LIST: 5, + }, + INPUT_KEY: { + OWNS_MORE_THAN_25_PERCENT: 'ownsMoreThan25Percent', + HAS_OTHER_BENEFICIAL_OWNERS: 'hasOtherBeneficialOwners', + BENEFICIAL_OWNERS: 'beneficialOwners', + }, + BENEFICIAL_OWNER_DATA: { + BENEFICIAL_OWNER_KEYS: 'beneficialOwnerKeys', + PREFIX: 'beneficialOwner', + FIRST_NAME: 'firstName', + LAST_NAME: 'lastName', + DOB: 'dob', + SSN_LAST_4: 'ssnLast4', + STREET: 'street', + CITY: 'city', + STATE: 'state', + ZIP_CODE: 'zipCode', + }, + }, PLAID: { ALLOWED_THROTTLED_COUNT: 2, ERROR: { @@ -205,6 +270,13 @@ const CONST = { EXIT: 'EXIT', }, }, + COMPLETE_VERIFICATION: { + INPUT_KEY: { + IS_AUTHORIZED_TO_USE_BANK_ACCOUNT: 'isAuthorizedToUseBankAccount', + CERTIFY_TRUE_INFORMATION: 'certifyTrueInformation', + ACCEPT_TERMS_AND_CONDITIONS: 'acceptTermsAndConditions', + }, + }, ERROR: { MISSING_ROUTING_NUMBER: '402 Missing routingNumber', MAX_ROUTING_NUMBER: '402 Maximum Size Exceeded routingNumber', @@ -214,14 +286,18 @@ const CONST = { STEP: { // In the order they appear in the VBA flow BANK_ACCOUNT: 'BankAccountStep', - COMPANY: 'CompanyStep', REQUESTOR: 'RequestorStep', + COMPANY: 'CompanyStep', + BENEFICIAL_OWNERS: 'BeneficialOwnersStep', ACH_CONTRACT: 'ACHContractStep', VALIDATION: 'ValidationStep', ENABLE: 'EnableStep', }, + STEP_NAMES: ['1', '2', '3', '4', '5'], + STEPS_HEADER_HEIGHT: 40, SUBSTEP: { MANUAL: 'manual', + PLAID: 'plaid', }, VERIFICATIONS: { ERROR_MESSAGE: 'verifications.errorMessage', @@ -493,6 +569,8 @@ const CONST = { ONFIDO_FACIAL_SCAN_POLICY_URL: 'https://onfido.com/facial-scan-policy-and-release/', ONFIDO_PRIVACY_POLICY_URL: 'https://onfido.com/privacy/', ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/', + LIST_OF_RESTRICTED_BUSINESSES: 'https://community.expensify.com/discussion/6191/list-of-restricted-businesses', + // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', OLDDOT_URLS: { @@ -3194,6 +3272,34 @@ const CONST = { }, REPORT_FIELD_TITLE_FIELD_ID: 'text_title', + + REIMBURSEMENT_ACCOUNT_SUBSTEP_INDEX: { + BANK_ACCOUNT: { + ACCOUNT_NUMBERS: 0, + }, + PERSONAL_INFO: { + LEGAL_NAME: 0, + DATE_OF_BIRTH: 1, + SSN: 2, + ADDRESS: 3, + }, + BUSINESS_INFO: { + BUSINESS_NAME: 0, + TAX_ID_NUMBER: 1, + COMPANY_WEBSITE: 2, + PHONE_NUMBER: 3, + COMPANY_ADDRESS: 4, + COMPANY_TYPE: 5, + INCORPORATION_DATE: 6, + INCORPORATION_STATE: 7, + }, + UBO: { + LEGAL_NAME: 0, + DATE_OF_BIRTH: 1, + SSN: 2, + ADDRESS: 3, + }, + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b46d3db8b60d..751ee105ceb7 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -63,6 +63,14 @@ const ONYXKEYS = { /** Contains all the info for Tasks */ TASK: 'task', + /** + * Contains all the info for Workspace Rate and Unit while editing. + * + * Note: This is not under the COLLECTION key as we can edit rate and unit + * for one workspace only at a time. And we don't need to store + * rates and units for different workspaces at the same time. */ + WORKSPACE_RATE_AND_UNIT: 'workspaceRateAndUnit', + /** Contains a list of all currencies available to the user - user can * select a currency based on the list */ CURRENCY_LIST: 'currencyList', @@ -133,6 +141,7 @@ const ONYXKEYS = { /** Token needed to initialize Onfido */ ONFIDO_TOKEN: 'onfidoToken', + ONFIDO_APPLICANT_ID: 'onfidoApplicantID', /** Indicates which locale should be used */ NVP_PREFERRED_LOCALE: 'preferredLocale', @@ -393,6 +402,7 @@ type OnyxValues = { [ONYXKEYS.PERSONAL_DETAILS_LIST]: OnyxTypes.PersonalDetailsList; [ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails; [ONYXKEYS.TASK]: OnyxTypes.Task; + [ONYXKEYS.WORKSPACE_RATE_AND_UNIT]: OnyxTypes.WorkspaceRateAndUnit; [ONYXKEYS.CURRENCY_LIST]: Record; [ONYXKEYS.UPDATE_AVAILABLE]: boolean; [ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest; @@ -417,6 +427,7 @@ type OnyxValues = { [ONYXKEYS.IS_PLAID_DISABLED]: boolean; [ONYXKEYS.PLAID_LINK_TOKEN]: string; [ONYXKEYS.ONFIDO_TOKEN]: string; + [ONYXKEYS.ONFIDO_APPLICANT_ID]: string; [ONYXKEYS.NVP_PREFERRED_LOCALE]: OnyxTypes.Locale; [ONYXKEYS.USER_WALLET]: OnyxTypes.UserWallet; [ONYXKEYS.WALLET_ONFIDO]: OnyxTypes.WalletOnfido; @@ -450,6 +461,7 @@ type OnyxValues = { [ONYXKEYS.MAX_CANVAS_AREA]: number; [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; + [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.UPDATE_REQUIRED]: boolean; @@ -553,8 +565,8 @@ type OnyxValues = { [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: OnyxTypes.ReportFieldEditForm; [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM_DRAFT]: OnyxTypes.Form; // @ts-expect-error Different values are defined under the same key: ReimbursementAccount and ReimbursementAccountForm - [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: OnyxTypes.ReimbursementAccountForm; + [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT]: OnyxTypes.ReimbursementAccountForm; [ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT]: OnyxTypes.PersonalBankAccount; [ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_DRAFT]: OnyxTypes.PersonalBankAccount; }; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5a2ab8cfc7de..082cea49d771 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -272,10 +272,6 @@ const ROUTES = { route: ':iouType/new/confirmation/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}` as const, }, - MONEY_REQUEST_DATE: { - route: ':iouType/new/date/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}` as const, - }, MONEY_REQUEST_CURRENCY: { route: ':iouType/new/currency/:reportID?', getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const, @@ -337,9 +333,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/currency/${transactionID}/${reportID}/${pageIndex}`, backTo), }, MONEY_REQUEST_STEP_DATE: { - route: 'create/:iouType/date/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/date/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/date/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/date/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_DESCRIPTION: { route: ':action/:iouType/description/:transactionID/:reportID', @@ -468,6 +464,14 @@ const ROUTES = { route: 'workspace/:policyID/rateandunit', getRoute: (policyID: string) => `workspace/${policyID}/rateandunit` as const, }, + WORKSPACE_RATE_AND_UNIT_RATE: { + route: 'workspace/:policyID/rateandunit/rate', + getRoute: (policyID: string) => `workspace/${policyID}/rateandunit/rate` as const, + }, + WORKSPACE_RATE_AND_UNIT_UNIT: { + route: 'workspace/:policyID/rateandunit/unit', + getRoute: (policyID: string) => `workspace/${policyID}/rateandunit/unit` as const, + }, WORKSPACE_BILLS: { route: 'workspace/:policyID/bills', getRoute: (policyID: string) => `workspace/${policyID}/bills` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1d7c77bf129c..1626fdbd1898 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -146,7 +146,6 @@ const SCREENS = { PARTICIPANTS: 'Money_Request_Participants', CONFIRMATION: 'Money_Request_Confirmation', CURRENCY: 'Money_Request_Currency', - DATE: 'Money_Request_Date', CATEGORY: 'Money_Request_Category', MERCHANT: 'Money_Request_Merchant', WAYPOINT: 'Money_Request_Waypoint', @@ -198,6 +197,8 @@ const SCREENS = { CARD: 'Workspace_Card', REIMBURSE: 'Workspace_Reimburse', RATE_AND_UNIT: 'Workspace_RateAndUnit', + RATE_AND_UNIT_RATE: 'Workspace_RateAndUnit_Rate', + RATE_AND_UNIT_UNIT: 'Workspace_RateAndUnit_Unit', BILLS: 'Workspace_Bills', INVOICES: 'Workspace_Invoices', TRAVEL: 'Workspace_Travel', diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index a5160a13f8e9..b6fc639546a8 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -1,6 +1,6 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -16,10 +16,12 @@ import * as BankAccounts from '@userActions/BankAccounts'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView'; +import FormHelpMessage from './FormHelpMessage'; import Icon from './Icon'; import getBankIcon from './Icon/BankIcons'; import Picker from './Picker'; import PlaidLink from './PlaidLink'; +import RadioButtons from './RadioButtons'; import Text from './Text'; const propTypes = { @@ -55,6 +57,15 @@ const propTypes = { /** Are we adding a withdrawal account? */ allowDebit: PropTypes.bool, + + /** Is displayed in new VBBA */ + isDisplayedInNewVBBA: PropTypes.bool, + + /** Text to display on error message */ + errorText: PropTypes.string, + + /** Function called whenever radio button value changes */ + onInputChange: PropTypes.func, }; const defaultProps = { @@ -68,6 +79,9 @@ const defaultProps = { allowDebit: false, bankAccountID: 0, isPlaidDisabled: false, + isDisplayedInNewVBBA: false, + errorText: '', + onInputChange: () => {}, }; function AddPlaidBankAccount({ @@ -82,11 +96,23 @@ function AddPlaidBankAccount({ bankAccountID, allowDebit, isPlaidDisabled, + isDisplayedInNewVBBA, + errorText, + onInputChange, }) { const theme = useTheme(); const styles = useThemeStyles(); + const plaidBankAccounts = lodashGet(plaidData, 'bankAccounts', []); + const defaultSelectedPlaidAccount = _.find(plaidBankAccounts, (account) => account.plaidAccountID === selectedPlaidAccountID); + const defaultSelectedPlaidAccountID = lodashGet(defaultSelectedPlaidAccount, 'plaidAccountID', ''); + const defaultSelectedPlaidAccountMask = lodashGet( + _.find(plaidBankAccounts, (account) => account.plaidAccountID === selectedPlaidAccountID), + 'mask', + '', + ); const subscribedKeyboardShortcuts = useRef([]); const previousNetworkState = useRef(); + const [selectedPlaidAccountMask, setSelectedPlaidAccountMask] = useState(defaultSelectedPlaidAccountMask); const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -162,17 +188,28 @@ function AddPlaidBankAccount({ previousNetworkState.current = isOffline; }, [allowDebit, bankAccountID, isAuthenticatedWithPlaid, isOffline]); - const plaidBankAccounts = lodashGet(plaidData, 'bankAccounts') || []; const token = getPlaidLinkToken(); const options = _.map(plaidBankAccounts, (account) => ({ value: account.plaidAccountID, - label: `${account.addressName} ${account.mask}`, + label: account.addressName, })); const {icon, iconSize, iconStyles} = getBankIcon({styles}); const plaidErrors = lodashGet(plaidData, 'errors'); const plaidDataErrorMessage = !_.isEmpty(plaidErrors) ? _.chain(plaidErrors).values().first().value() : ''; const bankName = lodashGet(plaidData, 'bankName'); + /** + * @param {String} plaidAccountID + * + * When user selects one of plaid accounts we need to set the mask in order to display it on UI + */ + const handleSelectingPlaidAccount = (plaidAccountID) => { + const mask = _.find(plaidBankAccounts, (account) => account.plaidAccountID === plaidAccountID).mask; + setSelectedPlaidAccountMask(mask); + onSelect(plaidAccountID); + onInputChange(plaidAccountID); + }; + if (isPlaidDisabled) { return ( @@ -239,6 +276,37 @@ function AddPlaidBankAccount({ return {renderPlaidLink()}; } + if (isDisplayedInNewVBBA) { + return ( + + {translate('bankAccount.chooseAnAccount')} + {!_.isEmpty(text) && {text}} + + + + {bankName} + {selectedPlaidAccountMask.length > 0 && ( + {`${translate('bankAccount.accountEnding')} ${selectedPlaidAccountMask}`} + )} + + + {`${translate('bankAccount.chooseAnAccountBelow')}:`} + + + + ); + } + // Plaid bank accounts view return ( diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx new file mode 100644 index 000000000000..4214d804af06 --- /dev/null +++ b/src/components/AmountForm.tsx @@ -0,0 +1,241 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {NativeSyntheticEvent, TextInput, TextInputSelectionChangeEventData} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as Browser from '@libs/Browser'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import getOperatingSystem from '@libs/getOperatingSystem'; +import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import CONST from '@src/CONST'; +import BigNumberPad from './BigNumberPad'; +import FormHelpMessage from './FormHelpMessage'; +import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; + +type AmountFormProps = { + /** Amount supplied by the FormProvider */ + value?: string; + + /** Currency supplied by user */ + currency?: string; + + /** Tells how many extra decimal digits are allowed. Default is 0. */ + extraDecimals?: number; + + /** Error to display at the bottom of the component */ + errorText?: string; + + /** Callback to update the amount in the FormProvider */ + onInputChange?: (value: string) => void; + + /** Fired when back button pressed, navigates to currency selection page */ + onCurrencyButtonPress?: () => void; +}; + +/** + * Returns the new selection object based on the updated amount's length + */ +const getNewSelection = (oldSelection: {start: number; end: number}, prevLength: number, newLength: number) => { + const cursorPosition = oldSelection.end + (newLength - prevLength); + return {start: cursorPosition, end: cursorPosition}; +}; + +const AMOUNT_VIEW_ID = 'amountView'; +const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; +const NUM_PAD_VIEW_ID = 'numPadView'; + +function AmountForm( + {value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, errorText, onInputChange, onCurrencyButtonPress}: AmountFormProps, + forwardedRef: ForwardedRef, +) { + const styles = useThemeStyles(); + const {toLocaleDigit, numberFormat} = useLocalize(); + + const textInput = useRef(null); + + const decimals = CurrencyUtils.getCurrencyDecimals(currency) + extraDecimals; + const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); + + const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true); + + const [selection, setSelection] = useState({ + start: currentAmount.length, + end: currentAmount.length, + }); + + const forwardDeletePressedRef = useRef(false); + + /** + * Event occurs when a user presses a mouse button over an DOM element. + */ + const focusTextInput = (event: React.MouseEvent, ids: string[]) => { + const relatedTargetId = (event.nativeEvent?.target as HTMLElement | null)?.id ?? ''; + if (!ids.includes(relatedTargetId)) { + return; + } + event.preventDefault(); + if (!textInput.current) { + return; + } + if (!textInput.current.isFocused()) { + textInput.current.focus(); + } + }; + + /** + * Sets the selection and the amount accordingly to the value passed to the input + * @param newAmount - Changed amount from user input + */ + const setNewAmount = useCallback( + (newAmount: string) => { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); + // Use a shallow copy of selection to trigger setSelection + // More info: https://github.com/Expensify/App/issues/16385 + if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces, decimals)) { + setSelection((prevSelection) => ({...prevSelection})); + return; + } + + const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); + const isForwardDelete = currentAmount.length > strippedAmount.length && forwardDeletePressedRef.current; + setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length)); + onInputChange?.(strippedAmount); + }, + [currentAmount, decimals, onInputChange], + ); + + // Modifies the amount to match the decimals for changed currency. + useEffect(() => { + // If the changed currency supports decimals, we can return + if (MoneyRequestUtils.validateAmount(currentAmount, decimals)) { + return; + } + + // If the changed currency doesn't support decimals, we can strip the decimals + setNewAmount(MoneyRequestUtils.stripDecimalsFromAmount(currentAmount)); + + // we want to update only when decimals change (setNewAmount also changes when decimals change). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [decimals]); + + /** + * Update amount with number or Backspace pressed for BigNumberPad. + * Validate new amount with decimal number regex up to 6 digits and 2 decimal digit to enable Next button + */ + const updateAmountNumberPad = useCallback( + (key: string) => { + if (shouldUpdateSelection && !textInput.current?.isFocused()) { + textInput.current?.focus(); + } + // Backspace button is pressed + if (key === '<' || key === 'Backspace') { + if (currentAmount.length > 0) { + const selectionStart = selection.start === selection.end ? selection.start - 1 : selection.start; + const newAmount = `${currentAmount.substring(0, selectionStart)}${currentAmount.substring(selection.end)}`; + setNewAmount(MoneyRequestUtils.addLeadingZero(newAmount)); + } + return; + } + const newAmount = MoneyRequestUtils.addLeadingZero(`${currentAmount.substring(0, selection.start)}${key}${currentAmount.substring(selection.end)}`); + setNewAmount(newAmount); + }, + [currentAmount, selection, shouldUpdateSelection, setNewAmount], + ); + + /** + * Update long press value, to remove items pressing on < + * + * @param value - Changed text from user input + */ + const updateLongPressHandlerState = useCallback((value: boolean) => { + setShouldUpdateSelection(!value); + if (!value && !textInput.current?.isFocused()) { + textInput.current?.focus(); + } + }, []); + + /** + * Input handler to check for a forward-delete key (or keyboard shortcut) press. + */ + const textInputKeyPress = (event: NativeSyntheticEvent) => { + const key = event.nativeEvent.key.toLowerCase(); + if (Browser.isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) { + // Optimistically anticipate forward-delete on iOS Safari (in cases where the Mac Accessiblity keyboard is being + // used for input). If the Control-D shortcut doesn't get sent, the ref will still be reset on the next key press. + forwardDeletePressedRef.current = true; + return; + } + // Control-D on Mac is a keyboard shortcut for forward-delete. See https://support.apple.com/en-us/HT201236 for Mac keyboard shortcuts. + // Also check for the keyboard shortcut on iOS in cases where a hardware keyboard may be connected to the device. + const operatingSystem = getOperatingSystem() as string | null; + const allowedOS: string[] = [CONST.OS.MAC_OS, CONST.OS.IOS]; + forwardDeletePressedRef.current = key === 'delete' || (allowedOS.includes(operatingSystem ?? '') && event.nativeEvent.ctrlKey && key === 'd'); + }; + + const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); + const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); + + return ( + <> + focusTextInput(event, [AMOUNT_VIEW_ID])} + style={[styles.moneyRequestAmountContainer, styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]} + > + { + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef && 'current' in forwardedRef) { + // eslint-disable-next-line no-param-reassign + forwardedRef.current = ref; + } + textInput.current = ref; + }} + selectedCurrencyCode={currency} + selection={selection} + onSelectionChange={(e: NativeSyntheticEvent) => { + if (!shouldUpdateSelection) { + return; + } + setSelection(e.nativeEvent.selection); + }} + onKeyPress={textInputKeyPress} + /> + {!!errorText && ( + + )} + + {canUseTouchScreen ? ( + focusTextInput(event, [NUM_PAD_CONTAINER_VIEW_ID, NUM_PAD_VIEW_ID])} + style={[styles.w100, styles.justifyContentEnd, styles.pageWrapper, styles.pt0]} + id={NUM_PAD_CONTAINER_VIEW_ID} + > + + + ) : null} + + ); +} + +AmountForm.displayName = 'AmountForm'; + +export default forwardRef(AmountForm); diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx index 80f7794d0ad3..de43bf548a98 100644 --- a/src/components/BigNumberPad.tsx +++ b/src/components/BigNumberPad.tsx @@ -17,7 +17,7 @@ type BigNumberPadProps = { id?: string; /** Whether long press is disabled */ - isLongPressDisabled: boolean; + isLongPressDisabled?: boolean; }; const padNumbers = [ diff --git a/src/components/CustomStatusBarAndBackground/index.tsx b/src/components/CustomStatusBarAndBackground/index.tsx index 42ea96fe41bb..c653fec73e91 100644 --- a/src/components/CustomStatusBarAndBackground/index.tsx +++ b/src/components/CustomStatusBarAndBackground/index.tsx @@ -116,15 +116,17 @@ function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBack return () => navigationRef.removeListener('state', listener); }, [isDisabled, theme.appBG, updateStatusBarStyle]); - // Update the global background (on web) everytime the theme changes. + // Update the global background and status bar style (on web) everytime the theme changes. // The background of the html element needs to be updated, otherwise you will see a big contrast when resizing the window or when the keyboard is open on iOS web. + // The status bar style needs to be updated when the user changes the theme, otherwise, the status bar will not change its color (mWeb iOS). useEffect(() => { if (isDisabled) { return; } updateGlobalBackgroundColor(theme); - }, [isDisabled, theme]); + updateStatusBarStyle(); + }, [isDisabled, theme, updateStatusBarStyle]); if (isDisabled) { return null; diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index a2ca930690ac..759908a4647f 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.js @@ -1,7 +1,7 @@ import {setYear} from 'date-fns'; import _ from 'lodash'; import PropTypes from 'prop-types'; -import React, {forwardRef, useState} from 'react'; +import React, {forwardRef, useEffect, useState} from 'react'; import {View} from 'react-native'; import * as Expensicons from '@components/Icon/Expensicons'; import refPropTypes from '@components/refPropTypes'; @@ -9,6 +9,7 @@ import TextInput from '@components/TextInput'; import {propTypes as baseTextInputPropTypes, defaultProps as defaultBaseTextInputPropTypes} from '@components/TextInput/BaseTextInput/baseTextInputPropTypes'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; import CalendarPicker from './CalendarPicker'; @@ -42,6 +43,12 @@ const propTypes = { /** A function that is passed by FormWrapper */ onTouched: PropTypes.func.isRequired, + /** Saves a draft of the input value when used in a form */ + shouldSaveDraft: PropTypes.bool, + + /** ID of the wrapping form */ + formID: PropTypes.string, + ...baseTextInputPropTypes, }; @@ -50,9 +57,28 @@ const datePickerDefaultProps = { minDate: setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR), maxDate: setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR), value: undefined, + shouldSaveDraft: false, + formID: '', }; -function DatePicker({forwardedRef, containerStyles, defaultValue, disabled, errorText, inputID, isSmallScreenWidth, label, maxDate, minDate, onInputChange, onTouched, placeholder, value}) { +function DatePicker({ + forwardedRef, + containerStyles, + defaultValue, + disabled, + errorText, + inputID, + isSmallScreenWidth, + label, + maxDate, + minDate, + onInputChange, + onTouched, + placeholder, + value, + shouldSaveDraft, + formID, +}) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [selectedDate, setSelectedDate] = useState(value || defaultValue || undefined); @@ -67,6 +93,19 @@ function DatePicker({forwardedRef, containerStyles, defaultValue, disabled, erro setSelectedDate(newValue); }; + useEffect(() => { + // Value is provided to input via props and onChange never fires. We have to save draft manually. + if (shouldSaveDraft && formID !== '') { + FormActions.setDraftValues(formID, {[inputID]: selectedDate}); + } + + if (selectedDate === value || _.isUndefined(value)) { + return; + } + + setSelectedDate(value); + }, [formID, inputID, selectedDate, shouldSaveDraft, value]); + return ( diff --git a/src/components/DistanceRequest/index.js b/src/components/DistanceRequest/index.js index 419e22e8a158..eafc36a57927 100644 --- a/src/components/DistanceRequest/index.js +++ b/src/components/DistanceRequest/index.js @@ -194,21 +194,17 @@ function DistanceRequest({transactionID, report, transaction, route, isEditingRe } const newWaypoints = {}; - let emptyWaypointIndex = -1; _.each(data, (waypoint, index) => { newWaypoints[`waypoint${index}`] = lodashGet(waypoints, waypoint, {}); - // Find waypoint that BECOMES empty after dragging - if (_.isEmpty(newWaypoints[`waypoint${index}`]) && !_.isEmpty(lodashGet(waypoints, `waypoint${index}`, {}))) { - emptyWaypointIndex = index; - } }); setOptimisticWaypoints(newWaypoints); - Promise.all([Transaction.removeWaypoint(transaction, emptyWaypointIndex.toString(), true), Transaction.updateWaypoints(transactionID, newWaypoints, true)]).then(() => { + // eslint-disable-next-line rulesdir/no-thenable-actions-in-views + Transaction.updateWaypoints(transactionID, newWaypoints).then(() => { setOptimisticWaypoints(null); }); }, - [transactionID, transaction, waypoints, waypointsList], + [transactionID, waypoints, waypointsList], ); const submitWaypoints = useCallback(() => { diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index f336fe659074..b08d2106ff93 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -109,6 +109,9 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected, activeEmoji}) { initialFocusedIndex: -1, disableCyclicTraversal: true, onFocusedIndexChange, + disableHorizontalKeys: isFocused, + // We pass true without checking visibility of the component because if the popover is not visible this picker won't be mounted + isActive: true, }); const filterEmojis = _.throttle((searchTerm) => { diff --git a/src/components/FeatureList.js b/src/components/FeatureList.tsx similarity index 66% rename from src/components/FeatureList.js rename to src/components/FeatureList.tsx index 14b807aed85e..f46b289b88d7 100644 --- a/src/components/FeatureList.js +++ b/src/components/FeatureList.tsx @@ -1,60 +1,61 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; +import type {StyleProp, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import stylePropTypes from '@styles/stylePropTypes'; import variables from '@styles/variables'; +import type {TranslationPaths} from '@src/languages/types'; +import type IconAsset from '@src/types/utils/IconAsset'; import Button from './Button'; +import type DotLottieAnimation from './LottieAnimations/types'; import MenuItem from './MenuItem'; -import menuItemPropTypes from './menuItemPropTypes'; import Section from './Section'; -const propTypes = { +type FeatureListItem = { + icon: IconAsset; + translationKey: TranslationPaths; +}; + +type FeatureListProps = { /** The text to display in the title of the section */ - title: PropTypes.string.isRequired, + title: string; /** The text to display in the subtitle of the section */ - subtitle: PropTypes.string, + subtitle?: string; /** Text of the call to action button */ - ctaText: PropTypes.string, + ctaText?: string; /** Accessibility label for the call to action button */ - ctaAccessibilityLabel: PropTypes.string, + ctaAccessibilityLabel?: string; /** Action to call on cta button press */ - onCtaPress: PropTypes.func, + onCtaPress?: () => void; /** A list of menuItems representing the feature list. */ - menuItems: PropTypes.arrayOf(PropTypes.shape({...menuItemPropTypes, translationKey: PropTypes.string})).isRequired, + menuItems: FeatureListItem[]; /** The illustration to display in the header. Can be a JSON object representing a Lottie animation. */ - illustration: PropTypes.shape({ - file: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - w: PropTypes.number.isRequired, - h: PropTypes.number.isRequired, - }), + illustration: DotLottieAnimation; /** The style passed to the illustration */ - illustrationStyle: stylePropTypes, + illustrationStyle?: StyleProp; /** The background color to apply in the upper half of the screen. */ - illustrationBackgroundColor: PropTypes.string, -}; - -const defaultProps = { - ctaText: '', - ctaAccessibilityLabel: '', - subtitle: '', - onCtaPress: () => {}, - illustration: null, - illustrationBackgroundColor: '', - illustrationStyle: [], + illustrationBackgroundColor?: string; }; -function FeatureList({title, subtitle, ctaText, ctaAccessibilityLabel, onCtaPress, menuItems, illustration, illustrationStyle, illustrationBackgroundColor}) { +function FeatureList({ + title, + subtitle = '', + ctaText = '', + ctaAccessibilityLabel = '', + onCtaPress, + menuItems, + illustration, + illustrationStyle, + illustrationBackgroundColor, +}: FeatureListProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -70,7 +71,7 @@ function FeatureList({title, subtitle, ctaText, ctaAccessibilityLabel, onCtaPres > - {_.map(menuItems, ({translationKey, icon}) => ( + {menuItems.map(({translationKey, icon}) => ( @@ -101,8 +102,7 @@ function FeatureList({title, subtitle, ctaText, ctaAccessibilityLabel, onCtaPres ); } -FeatureList.propTypes = propTypes; -FeatureList.defaultProps = defaultProps; FeatureList.displayName = 'FeatureList'; export default FeatureList; +export type {FeatureListItem}; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index ba0f823fdbad..82a8d0870946 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -1,7 +1,7 @@ import lodashIsEqual from 'lodash/isEqual'; import type {ForwardedRef, MutableRefObject, ReactNode} from 'react'; import React, {createRef, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import type {NativeSyntheticEvent, TextInputSubmitEditingEventData} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import * as ValidationUtils from '@libs/ValidationUtils'; @@ -62,6 +62,12 @@ type FormProviderProps = FormProvider /** Should validate function be called when the value of the input is changed */ shouldValidateOnChange?: boolean; + + /** Styles that will be applied to the submit button only */ + submitButtonStyles?: StyleProp; + + /** Whether to apply flex to the submit button */ + submitFlexEnabled?: boolean; }; type FormRef = { diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 074069ec3ea7..e8778cf25d58 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -28,6 +28,9 @@ type FormWrapperProps = ChildrenProps & /** Submit button styles */ submitButtonStyles?: StyleProp; + /** Whether to apply flex to the submit button */ + submitFlexEnabled?: boolean; + /** Server side errors keyed by microtime */ errors: Errors; @@ -49,6 +52,7 @@ function FormWrapper({ isSubmitButtonVisible = true, style, submitButtonStyles, + submitFlexEnabled = true, enabledWhenOffline, isSubmitActionDangerous = false, formID, @@ -109,7 +113,7 @@ function FormWrapper({ onSubmit={onSubmit} footerContent={footerContent} onFixTheErrorsLinkPressed={onFixTheErrorsLinkPressed} - containerStyles={[styles.mh0, styles.mt5, styles.flex1, submitButtonStyles]} + containerStyles={[styles.mh0, styles.mt5, submitFlexEnabled ? styles.flex1 : {}, submitButtonStyles]} enabledWhenOffline={enabledWhenOffline} isSubmitActionDangerous={isSubmitActionDangerous} disablePressOnEnter={disablePressOnEnter} @@ -134,6 +138,7 @@ function FormWrapper({ styles.mh0, styles.mt5, submitButtonStyles, + submitFlexEnabled, submitButtonText, shouldHideFixErrorsAlert, onFixTheErrorsLinkPressed, diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 353a6927caf7..2705b2c8fd4e 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -1,11 +1,14 @@ import type {ComponentProps, FocusEvent, Key, MutableRefObject, ReactNode, Ref} from 'react'; import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; import type AddressSearch from '@components/AddressSearch'; +import type AmountForm from '@components/AmountForm'; import type AmountTextInput from '@components/AmountTextInput'; import type CheckboxWithLabel from '@components/CheckboxWithLabel'; import type Picker from '@components/Picker'; import type SingleChoiceQuestion from '@components/SingleChoiceQuestion'; +import type StatePicker from '@components/StatePicker'; import type TextInput from '@components/TextInput'; +import type BusinessTypePicker from '@pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker'; import type {OnyxFormKey, OnyxValues} from '@src/ONYXKEYS'; import type Form from '@src/types/onyx/Form'; import type {BaseForm, FormValueType} from '@src/types/onyx/Form'; @@ -17,7 +20,16 @@ import type {BaseForm, FormValueType} from '@src/types/onyx/Form'; * TODO: Add remaining inputs here once these components are migrated to Typescript: * CountrySelector | StatePicker | DatePicker | EmojiPickerButtonDropdown | RoomNameInput | ValuePicker */ -type ValidInputs = typeof TextInput | typeof AmountTextInput | typeof SingleChoiceQuestion | typeof CheckboxWithLabel | typeof Picker | typeof AddressSearch; +type ValidInputs = + | typeof TextInput + | typeof AmountTextInput + | typeof SingleChoiceQuestion + | typeof CheckboxWithLabel + | typeof Picker + | typeof AddressSearch + | typeof AmountForm + | typeof BusinessTypePicker + | typeof StatePicker; type ValueTypeKey = 'string' | 'boolean' | 'date'; @@ -71,6 +83,9 @@ type FormProps = { /** Text to be displayed in the submit button */ submitButtonText: string; + /** Submit button styles */ + submitButtonStyles?: StyleProp; + /** Controls the submit button's visibility */ isSubmitButtonVisible?: boolean; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx index d1c11dc12ed5..788db4ea4de2 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import type {TextStyle} from 'react-native'; import {splitBoxModelStyle} from 'react-native-render-html'; import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import * as HTMLEngineUtils from '@components/HTMLEngineProvider/htmlEngineUtils'; @@ -41,7 +40,7 @@ function CodeRenderer({TDefaultRenderer, key, style, ...defaultRendererProps}: C return ( void; + + /** The index of the step to start with */ + startStepIndex?: number; +}; + +type InteractiveStepSubHeaderHandle = { + /** Move to the next step */ + moveNext: () => void; +}; + +const MIN_AMOUNT_FOR_EXPANDING = 3; +const MIN_AMOUNT_OF_STEPS = 2; + +function InteractiveStepSubHeader({stepNames, startStepIndex = 0, onStepSelected}: InteractiveStepSubHeaderProps, ref: ForwardedRef) { + const styles = useThemeStyles(); + const containerWidthStyle: ViewStyle = stepNames.length < MIN_AMOUNT_FOR_EXPANDING ? styles.mnw60 : styles.mnw100; + + if (stepNames.length < MIN_AMOUNT_OF_STEPS) { + throw new Error(`stepNames list must have at least ${MIN_AMOUNT_OF_STEPS} elements.`); + } + + const [currentStep, setCurrentStep] = useState(startStepIndex); + useImperativeHandle( + ref, + () => ({ + moveNext: () => { + setCurrentStep((actualStep) => actualStep + 1); + }, + }), + [], + ); + + const amountOfUnions = stepNames.length - 1; + + return ( + + {stepNames.map((stepName, index) => { + const isCompletedStep = currentStep > index; + const isLockedStep = currentStep < index; + const isLockedLine = currentStep < index + 1; + const hasUnion = index < amountOfUnions; + + const moveToStep = () => { + if (isLockedStep || !onStepSelected) { + return; + } + setCurrentStep(index); + onStepSelected(stepNames[index]); + }; + + return ( + + + {isCompletedStep ? ( + + ) : ( + {index + 1} + )} + + {hasUnion ? : null} + + ); + })} + + ); +} + +InteractiveStepSubHeader.displayName = 'InteractiveStepSubHeader'; + +export type {InteractiveStepSubHeaderProps, InteractiveStepSubHeaderHandle}; + +export default forwardRef(InteractiveStepSubHeader); diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 81ab1ae33268..a55d329f39ee 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -6,9 +6,7 @@ import {withOnyx} from 'react-native-onyx'; import withCurrentReportID from '@components/withCurrentReportID'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -63,8 +61,6 @@ function LHNOptionsList({ const transactionID = itemParentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? itemParentReportAction.originalMessage.IOUTransactionID ?? '' : ''; const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? null; const itemComment = draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] ?? ''; - const participants = [...ReportUtils.getParticipantsIDs(itemFullReport), itemFullReport?.ownerAccountID, itemParentReportAction?.actorAccountID].filter(Boolean) as number[]; - const participantsPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, personalDetails); const sortedReportActions = ReportActionsUtils.getSortedReportActionsForDisplay(itemReportActions); const lastReportAction = sortedReportActions[0]; @@ -83,7 +79,7 @@ function LHNOptionsList({ reportActions={itemReportActions} parentReportAction={itemParentReportAction} policy={itemPolicy} - personalDetails={participantsPersonalDetails} + personalDetails={personalDetails ?? {}} transaction={itemTransaction} lastReportActionTransaction={lastReportActionTransaction} receiptTransactions={transactions} diff --git a/src/components/LottieAnimations/index.tsx b/src/components/LottieAnimations/index.tsx index fd593421232d..10dc195365aa 100644 --- a/src/components/LottieAnimations/index.tsx +++ b/src/components/LottieAnimations/index.tsx @@ -1,7 +1,8 @@ +import colors from '@styles/theme/colors'; import variables from '@styles/variables'; import type DotLottieAnimation from './types'; -const DotLottieAnimations: Record = { +const DotLottieAnimations = { ExpensifyLounge: { file: require('@assets/animations/ExpensifyLounge.lottie'), w: 1920, @@ -26,6 +27,7 @@ const DotLottieAnimations: Record = { file: require('@assets/animations/PreferencesDJ.lottie'), w: 375, h: 240, + backgroundColor: colors.blue500, }, ReviewingBankInfo: { file: require('@assets/animations/ReviewingBankInfo.lottie'), @@ -36,6 +38,7 @@ const DotLottieAnimations: Record = { file: require('@assets/animations/WorkspacePlanet.lottie'), w: 375, h: 240, + backgroundColor: colors.pink800, }, SaveTheWorld: { file: require('@assets/animations/SaveTheWorld.lottie'), @@ -46,6 +49,7 @@ const DotLottieAnimations: Record = { file: require('@assets/animations/Safe.lottie'), w: 625, h: 400, + backgroundColor: colors.ice500, }, Magician: { file: require('@assets/animations/Magician.lottie'), @@ -61,7 +65,8 @@ const DotLottieAnimations: Record = { file: require('@assets/animations/Coin.lottie'), w: 375, h: 240, + backgroundColor: colors.yellow600, }, -}; +} satisfies Record; export default DotLottieAnimations; diff --git a/src/components/LottieAnimations/types.ts b/src/components/LottieAnimations/types.ts index 6000b9f853f0..e1a6e0b66c74 100644 --- a/src/components/LottieAnimations/types.ts +++ b/src/components/LottieAnimations/types.ts @@ -1,9 +1,11 @@ import type {LottieViewProps} from 'lottie-react-native'; +import type {ColorValue} from '@styles/utils/types'; type DotLottieAnimation = { file: LottieViewProps['source']; w: number; h: number; + backgroundColor?: ColorValue; }; export default DotLottieAnimation; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 6163fa116561..cfb7cffb2799 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -57,7 +57,7 @@ type NoIcon = { icon?: undefined; }; -type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { +type MenuItemBaseProps = { /** Function to fire when component is pressed */ onPress?: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; @@ -79,6 +79,9 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { /** Used to apply styles specifically to the title */ titleStyle?: ViewStyle; + /** Any additional styles to apply on the badge element */ + badgeStyle?: ViewStyle; + /** Any adjustments to style when menu item is hovered or pressed */ hoverAndPressStyle?: StyleProp>; @@ -233,6 +236,8 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { isPaneMenu?: boolean; }; +type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; + function MenuItem( { interactive = true, @@ -244,6 +249,7 @@ function MenuItem( titleStyle, hoverAndPressStyle, descriptionTextStyle, + badgeStyle, viewMode = CONST.OPTION_MODE.DEFAULT, numberOfLinesTitle = 1, icon, @@ -562,7 +568,12 @@ function MenuItem( {badgeText && ( )} {/* Since subtitle can be of type number, we should allow 0 to be shown */} @@ -625,5 +636,5 @@ function MenuItem( MenuItem.displayName = 'MenuItem'; -export type {MenuItemProps}; +export type {IconProps, AvatarProps, NoIcon, MenuItemBaseProps, MenuItemProps}; export default forwardRef(MenuItem); diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index da9838701fa5..7608447a213e 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -703,11 +703,15 @@ function MoneyRequestConfirmationList(props) { style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { - if (props.isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.DATE)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_DATE.getRoute(props.iouType, props.reportID)); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DATE.getRoute( + CONST.IOU.ACTION.EDIT, + props.iouType, + transaction.transactionID, + props.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ); }} disabled={didConfirm} interactive={!props.isReadOnly} diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index a2f79d2696b8..418ba97a70b9 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -21,6 +21,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -549,6 +550,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ return; } + playSound(SOUNDS.DONE); setDidConfirm(true); onConfirm(selectedParticipants); } @@ -790,11 +792,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { - if (isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.DATE)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ); }} disabled={didConfirm} interactive={!isReadOnly} diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index cb6a2dcbe722..4396f4329606 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -114,7 +114,10 @@ class BaseOptionsSelector extends Component { componentDidUpdate(prevProps, prevState) { if (prevState.disableEnterShortCut !== this.state.disableEnterShortCut) { if (this.state.disableEnterShortCut) { - this.unsubscribeEnter(); + if (this.unsubscribeEnter) { + this.unsubscribeEnter(); + this.unsubscribeEnter = undefined; + } } else { this.subscribeToEnterShortcut(); } @@ -290,6 +293,9 @@ class BaseOptionsSelector extends Component { } subscribeToEnterShortcut() { + if (this.unsubscribeEnter) { + this.unsubscribeEnter(); + } const enterConfig = CONST.KEYBOARD_SHORTCUTS.ENTER; this.unsubscribeEnter = KeyboardShortcut.subscribe( enterConfig.shortcutKey, @@ -302,6 +308,9 @@ class BaseOptionsSelector extends Component { } subscribeToCtrlEnterShortcut() { + if (this.unsubscribeCTRLEnter) { + this.unsubscribeCTRLEnter(); + } const CTRLEnterConfig = CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER; this.unsubscribeCTRLEnter = KeyboardShortcut.subscribe( CTRLEnterConfig.shortcutKey, @@ -327,10 +336,12 @@ class BaseOptionsSelector extends Component { unSubscribeFromKeyboardShortcut() { if (this.unsubscribeEnter) { this.unsubscribeEnter(); + this.unsubscribeEnter = undefined; } if (this.unsubscribeCTRLEnter) { this.unsubscribeCTRLEnter(); + this.unsubscribeCTRLEnter = undefined; } } diff --git a/src/components/PDFView/index.native.js b/src/components/PDFView/index.native.js index bfdb80131aa6..3802fe7a2ea6 100644 --- a/src/components/PDFView/index.native.js +++ b/src/components/PDFView/index.native.js @@ -1,25 +1,21 @@ -import React, {Component} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import PDF from 'react-native-pdf'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Text from '@components/Text'; -import withKeyboardState, {keyboardStatePropTypes} from '@components/withKeyboardState'; -import withLocalize from '@components/withLocalize'; -import withStyleUtils, {withStyleUtilsPropTypes} from '@components/withStyleUtils'; -import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; -import withWindowDimensions from '@components/withWindowDimensions'; -import compose from '@libs/compose'; +import useKeyboardState from '@hooks/useKeyboardState'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; import PDFPasswordForm from './PDFPasswordForm'; import {defaultProps, propTypes as pdfViewPropTypes} from './pdfViewPropTypes'; const propTypes = { ...pdfViewPropTypes, - ...keyboardStatePropTypes, - ...withThemeStylesPropTypes, - ...withStyleUtilsPropTypes, }; /** @@ -36,41 +32,24 @@ const propTypes = { * so that PDFPasswordForm doesn't bounce when react-native-pdf/PDF * is (temporarily) rendered. */ -class PDFView extends Component { - constructor(props) { - super(props); - this.state = { - shouldRequestPassword: false, - shouldAttemptPDFLoad: true, - shouldShowLoadingIndicator: true, - isPasswordInvalid: false, - failedToLoadPDF: false, - successToLoadPDF: false, - password: '', - }; - this.initiatePasswordChallenge = this.initiatePasswordChallenge.bind(this); - this.attemptPDFLoadWithPassword = this.attemptPDFLoadWithPassword.bind(this); - this.finishPDFLoad = this.finishPDFLoad.bind(this); - this.handleFailureToLoadPDF = this.handleFailureToLoadPDF.bind(this); - } - - componentDidUpdate() { - this.props.onToggleKeyboard(this.props.isKeyboardShown); - } - - handleFailureToLoadPDF(error) { - if (error.message.match(/password/i)) { - this.initiatePasswordChallenge(); - return; - } - this.setState({ - failedToLoadPDF: true, - shouldAttemptPDFLoad: false, - shouldRequestPassword: false, - shouldShowLoadingIndicator: false, - }); - } +function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused, onScaleChanged, sourceURL, errorLabelStyles}) { + const [shouldRequestPassword, setShouldRequestPassword] = useState(false); + const [shouldAttemptPDFLoad, setShouldAttemptPDFLoad] = useState(true); + const [shouldShowLoadingIndicator, setShouldShowLoadingIndicator] = useState(true); + const [isPasswordInvalid, setIsPasswordInvalid] = useState(false); + const [failedToLoadPDF, setFailedToLoadPDF] = useState(false); + const [successToLoadPDF, setSuccessToLoadPDF] = useState(false); + const [password, setPassword] = useState(''); + const {windowWidth, windowHeight, isSmallScreenWidth} = useWindowDimensions(); + const {translate} = useLocalize(); + const themeStyles = useThemeStyles(); + const {isKeyboardShown} = useKeyboardState(); + const StyleUtils = useStyleUtils(); + + useEffect(() => { + onToggleKeyboard(isKeyboardShown); + }); /** * Initiate password challenge if message received from react-native-pdf/PDF @@ -80,100 +59,101 @@ class PDFView extends Component { * Note that the message doesn't specify whether the password is simply empty or * invalid. */ - initiatePasswordChallenge() { - this.setState({shouldShowLoadingIndicator: false}); + + const initiatePasswordChallenge = useCallback(() => { + setShouldShowLoadingIndicator(false); // Render password form, and don't render PDF and loading indicator. - this.setState({ - shouldRequestPassword: true, - shouldAttemptPDFLoad: false, - }); + setShouldRequestPassword(true); + setShouldAttemptPDFLoad(false); // The message provided by react-native-pdf doesn't indicate whether this // is an initial password request or if the password is invalid. So we just assume // that if a password was already entered then it's an invalid password error. - if (this.state.password) { - this.setState({isPasswordInvalid: true}); + if (password) { + setIsPasswordInvalid(true); } - } + }, [password]); + + const handleFailureToLoadPDF = (error) => { + if (error.message.match(/password/i)) { + initiatePasswordChallenge(); + return; + } + setFailedToLoadPDF(true); + setShouldShowLoadingIndicator(false); + setShouldRequestPassword(false); + setShouldAttemptPDFLoad(false); + }; /** * When the password is submitted via PDFPasswordForm, save the password * in state and attempt to load the PDF. Also show the loading indicator * since react-native-pdf/PDF will need to reload the PDF. * - * @param {String} password Password submitted via PDFPasswordForm + * @param {String} pdfPassword Password submitted via PDFPasswordForm */ - attemptPDFLoadWithPassword(password) { + const attemptPDFLoadWithPassword = (pdfPassword) => { // Render react-native-pdf/PDF so that it can validate the password. // Note that at this point in the password challenge, shouldRequestPassword is true. // Thus react-native-pdf/PDF will be rendered - but not visible. - this.setState({ - password, - shouldAttemptPDFLoad: true, - shouldShowLoadingIndicator: true, - }); - } - + setPassword(pdfPassword); + setShouldAttemptPDFLoad(true); + setShouldShowLoadingIndicator(true); + }; /** * After the PDF is successfully loaded hide PDFPasswordForm and the loading * indicator. */ - finishPDFLoad() { - this.setState({ - shouldRequestPassword: false, - shouldShowLoadingIndicator: false, - successToLoadPDF: true, - }); - this.props.onLoadComplete(); - } + const finishPDFLoad = () => { + setShouldRequestPassword(false); + setShouldShowLoadingIndicator(false); + setSuccessToLoadPDF(true); + onLoadComplete(); + }; - renderPDFView() { - const pdfStyles = [this.props.themeStyles.imageModalPDF, this.props.StyleUtils.getWidthAndHeightStyle(this.props.windowWidth, this.props.windowHeight)]; + function renderPDFView() { + const pdfStyles = [themeStyles.imageModalPDF, StyleUtils.getWidthAndHeightStyle(windowWidth, windowHeight)]; // If we haven't yet successfully validated the password and loaded the PDF, // then we need to hide the react-native-pdf/PDF component so that PDFPasswordForm // is positioned nicely. We're specifically hiding it because we still need to render // the PDF component so that it can validate the password. - if (this.state.shouldRequestPassword) { - pdfStyles.push(this.props.themeStyles.invisible); + if (shouldRequestPassword) { + pdfStyles.push(themeStyles.invisible); } - const containerStyles = - this.state.shouldRequestPassword && this.props.isSmallScreenWidth - ? [this.props.themeStyles.w100, this.props.themeStyles.flex1] - : [this.props.themeStyles.alignItemsCenter, this.props.themeStyles.flex1]; + const containerStyles = shouldRequestPassword && isSmallScreenWidth ? [themeStyles.w100, themeStyles.flex1] : [themeStyles.alignItemsCenter, themeStyles.flex1]; return ( - {this.state.failedToLoadPDF && ( - - {this.props.translate('attachmentView.failedToLoadPDF')} + {failedToLoadPDF && ( + + {translate('attachmentView.failedToLoadPDF')} )} - {this.state.shouldAttemptPDFLoad && ( + {shouldAttemptPDFLoad && ( } - source={{uri: this.props.sourceURL}} + source={{uri: sourceURL}} style={pdfStyles} - onError={this.handleFailureToLoadPDF} - password={this.state.password} - onLoadComplete={this.finishPDFLoad} - onPageSingleTap={this.props.onPress} - onScaleChanged={this.props.onScaleChanged} + onError={handleFailureToLoadPDF} + password={password} + onLoadComplete={finishPDFLoad} + onPageSingleTap={onPress} + onScaleChanged={onScaleChanged} /> )} - - {this.state.shouldRequestPassword && ( - + {shouldRequestPassword && ( + this.setState({isPasswordInvalid: false})} - isPasswordInvalid={this.state.isPasswordInvalid} - shouldShowLoadingIndicator={this.state.shouldShowLoadingIndicator} + isFocused={isFocused} + onSubmit={attemptPDFLoadWithPassword} + onPasswordUpdated={() => setIsPasswordInvalid(false)} + isPasswordInvalid={isPasswordInvalid} + shouldShowLoadingIndicator={shouldShowLoadingIndicator} /> )} @@ -181,23 +161,22 @@ class PDFView extends Component { ); } - render() { - return this.props.onPress && !this.state.successToLoadPDF ? ( - - {this.renderPDFView()} - - ) : ( - this.renderPDFView() - ); - } + return onPress && !successToLoadPDF ? ( + + {renderPDFView()} + + ) : ( + renderPDFView() + ); } +PDFView.displayName = 'PDFView'; PDFView.propTypes = propTypes; PDFView.defaultProps = defaultProps; -export default compose(withWindowDimensions, withKeyboardState, withLocalize, withThemeStyles, withStyleUtils)(PDFView); +export default PDFView; diff --git a/src/components/RadioButtons.tsx b/src/components/RadioButtons.tsx index cb6843af65c0..3407c5ad9afa 100644 --- a/src/components/RadioButtons.tsx +++ b/src/components/RadioButtons.tsx @@ -1,4 +1,5 @@ import React, {useState} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import RadioButtonWithLabel from './RadioButtonWithLabel'; @@ -12,21 +13,27 @@ type RadioButtonsProps = { /** List of choices to display via radio buttons */ items: Choice[]; + /** Default checked value */ + defaultCheckedValue?: string; + /** Callback to fire when selecting a radio button */ onPress: (value: string) => void; + + /** Style for radio button */ + radioButtonStyle?: StyleProp; }; -function RadioButtons({items, onPress}: RadioButtonsProps) { +function RadioButtons({items, onPress, defaultCheckedValue = '', radioButtonStyle}: RadioButtonsProps) { const styles = useThemeStyles(); - const [checkedValue, setCheckedValue] = useState(''); + const [checkedValue, setCheckedValue] = useState(defaultCheckedValue); return ( - + {items.map((item) => ( { setCheckedValue(item.value); return onPress(item.value); diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.js index bc0e70e64419..141e056afd93 100644 --- a/src/components/ReimbursementAccountLoadingIndicator.js +++ b/src/components/ReimbursementAccountLoadingIndicator.js @@ -4,7 +4,6 @@ import {StyleSheet, View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView'; -import FullScreenLoadingIndicator from './FullscreenLoadingIndicator'; import HeaderWithBackButton from './HeaderWithBackButton'; import Lottie from './Lottie'; import LottieAnimations from './LottieAnimations'; @@ -12,9 +11,6 @@ import ScreenWrapper from './ScreenWrapper'; import Text from './Text'; const propTypes = { - /** Whether the user is submitting verifications data */ - isSubmittingVerificationsData: PropTypes.bool.isRequired, - /** Method to trigger when pressing back button of the header */ onBackButtonPress: PropTypes.func.isRequired, }; @@ -33,22 +29,18 @@ function ReimbursementAccountLoadingIndicator(props) { onBackButtonPress={props.onBackButtonPress} /> - {props.isSubmittingVerificationsData ? ( - - - - {translate('reimbursementAccountLoadingAnimation.explanationLine')} - + + + + {translate('reimbursementAccountLoadingAnimation.explanationLine')} - ) : ( - - )} + ); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index f0956da948c9..6b16f272e4c8 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -20,17 +20,19 @@ import type {ViolationField} from '@hooks/useViolations'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; -import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; +import Navigation from '@navigation/Navigation'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import * as IOU from '@userActions/IOU'; import * as Transaction from '@userActions/Transaction'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; @@ -196,6 +198,43 @@ function MoneyRequestView({ const pendingAction = transaction?.pendingAction; const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction; + const getErrorForField = useCallback( + (field: ViolationField) => { + // Checks applied when creating a new money request + // NOTE: receipt field can return multiple violations, so we need to handle it separately + const fieldChecks: Partial> = { + amount: { + isError: transactionAmount === 0, + translationPath: 'common.error.enterAmount', + }, + merchant: { + isError: !isSettled && !isCancelled && isPolicyExpenseChat && isEmptyMerchant, + translationPath: 'common.error.enterMerchant', + }, + date: { + isError: transactionDate === '', + translationPath: 'common.error.enterDate', + }, + }; + + const {isError, translationPath} = fieldChecks[field] ?? {}; + + // Return form errors if there are any + if (hasErrors && isError && translationPath) { + return translate(translationPath); + } + + // Return violations if there are any + if (canUseViolations && hasViolations(field)) { + const violations = getViolationsForField(field); + return ViolationsUtils.getViolationTranslation(violations[0], translate); + } + + return ''; + }, + [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, hasErrors, canUseViolations, hasViolations, translate, getViolationsForField], + ); + return ( @@ -253,10 +292,9 @@ function MoneyRequestView({ interactive={canEditAmount} shouldShowRightIcon={canEditAmount} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} - brickRoadIndicator={hasViolations('amount') || (hasErrors && transactionAmount === 0) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''} + brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('amount')} /> - {canUseViolations && } - {canUseViolations && } {isDistanceRequest ? ( @@ -297,14 +335,9 @@ function MoneyRequestView({ shouldShowRightIcon={canEditMerchant} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))} - brickRoadIndicator={ - hasViolations('merchant') || (!isSettled && !isCancelled && hasErrors && isEmptyMerchant && isPolicyExpenseChat) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - error={!isSettled && !isCancelled && hasErrors && isPolicyExpenseChat && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} + brickRoadIndicator={hasViolations('merchant') || (hasErrors && isEmptyMerchant && isPolicyExpenseChat) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={hasErrors && isPolicyExpenseChat && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} /> - {canUseViolations && } )} @@ -314,11 +347,12 @@ function MoneyRequestView({ interactive={canEditDate} shouldShowRightIcon={canEditDate} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} - brickRoadIndicator={hasViolations('date') || (hasErrors && transactionDate === '') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''} + onPress={() => + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) + } + brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('date')} /> - {canUseViolations && } {shouldShowCategory && ( @@ -329,9 +363,9 @@ function MoneyRequestView({ shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))} - brickRoadIndicator={hasViolations('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('category')} /> - {canUseViolations && } )} {shouldShowTag && ( @@ -345,9 +379,9 @@ function MoneyRequestView({ onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) } - brickRoadIndicator={hasViolations('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + brickRoadIndicator={getErrorForField('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('tag')} /> - {canUseViolations && } )} {isCardTransaction && ( @@ -369,13 +403,13 @@ function MoneyRequestView({ isOn={!!transactionBillable} onToggle={saveBillable} /> + {getErrorForField('billable') && ( + + )} - {hasViolations('billable') && ( - - )} )} diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx index 7737927e5307..33f5f3d2230a 100644 --- a/src/components/Section/index.tsx +++ b/src/components/Section/index.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {ReactNode} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -65,6 +66,9 @@ type SectionProps = ChildrenProps & { /** Styles to apply to illustration component */ illustrationStyle?: StyleProp; + + /** Overlay content to display on top of animation */ + overlayContent?: () => ReactNode; }; function Section({ @@ -84,13 +88,14 @@ function Section({ illustration, illustrationBackgroundColor, illustrationStyle, + overlayContent, }: SectionProps) { const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); const {isSmallScreenWidth} = useWindowDimensions(); - const illustrationContainerStyle: StyleProp = StyleUtils.getBackgroundColorStyle(illustrationBackgroundColor ?? theme.appBG); + const illustrationContainerStyle: StyleProp = StyleUtils.getBackgroundColorStyle(illustrationBackgroundColor ?? illustration?.backgroundColor ?? theme.appBG); return ( <> @@ -107,10 +112,12 @@ function Section({ + {overlayContent?.()} )} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index c77e244b4c92..3ac34c758c98 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -180,9 +180,8 @@ function BaseSelectionList( * Logic to run when a row is selected, either with click/press or keyboard hotkeys. * * @param item - the list item - * @param shouldUnfocusRow - flag to decide if we should unfocus all rows. True when selecting a row with click or press (not keyboard) */ - const selectRow = (item: TItem, shouldUnfocusRow = false) => { + const selectRow = (item: TItem) => { // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item if (canSelectMultiple) { if (sections.length > 1) { @@ -191,20 +190,11 @@ function BaseSelectionList( // we focus the first one after all the selected (selected items are always at the top). const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1; - if (!shouldUnfocusRow) { - setFocusedIndex(selectedOptionsCount); - } - if (!item.isSelected) { // If we're selecting an item, scroll to it's position at the top, so we can see it scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true); } } - - if (shouldUnfocusRow) { - // Unfocus all rows when selecting row with click/press - setFocusedIndex(-1); - } } onSelectRow(item); @@ -295,7 +285,7 @@ function BaseSelectionList( isDisabled={isDisabled} showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} - onSelectRow={() => selectRow(item, true)} + onSelectRow={() => selectRow(item)} onDismissError={onDismissError} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} rightHandSideComponent={rightHandSideComponent} diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index 80bedc84f069..50bfcd4cc8be 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as BankAccounts from '@userActions/BankAccounts'; import * as IOU from '@userActions/IOU'; import * as PaymentMethods from '@userActions/PaymentMethods'; @@ -201,6 +202,7 @@ function SettlementButton({ return; } + playSound(SOUNDS.DONE); onPress(iouPaymentType); }; diff --git a/src/components/StatePicker/index.tsx b/src/components/StatePicker/index.tsx index b00111319b4a..72262346b0d7 100644 --- a/src/components/StatePicker/index.tsx +++ b/src/components/StatePicker/index.tsx @@ -3,6 +3,7 @@ import React, {useState} from 'react'; import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import FormHelpMessage from '@components/FormHelpMessage'; +import type {MenuItemProps} from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -24,11 +25,14 @@ type StatePickerProps = { /** Label to display on field */ label?: string; + /** Any additional styles to apply */ + wrapperStyle?: MenuItemProps['wrapperStyle']; + /** Callback to call when the picker modal is dismissed */ onBlur?: () => void; }; -function StatePicker({value, onInputChange, label, onBlur, errorText = ''}: StatePickerProps, ref: ForwardedRef) { +function StatePicker({value, onInputChange, label, onBlur, errorText = '', wrapperStyle}: StatePickerProps, ref: ForwardedRef) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [isPickerVisible, setIsPickerVisible] = useState(false); @@ -63,11 +67,10 @@ function StatePicker({value, onInputChange, label, onBlur, errorText = ''}: Stat ref={ref} shouldShowRightIcon title={title} - // Label can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - description={label || translate('common.state')} + description={label ?? translate('common.state')} descriptionTextStyle={descStyle} onPress={showPickerModal} + wrapperStyle={wrapperStyle} /> diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index 410bd5081e25..3766ed71a149 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import * as ApiUtils from '@libs/ApiUtils'; import compose from '@libs/compose'; import * as Network from '@userActions/Network'; @@ -14,7 +13,6 @@ import Button from './Button'; import {withNetwork} from './OnyxProvider'; import Switch from './Switch'; import TestToolRow from './TestToolRow'; -import Text from './Text'; type TestToolMenuOnyxProps = { /** User object in Onyx */ @@ -29,17 +27,9 @@ const USER_DEFAULT: UserOnyx = {shouldUseStagingServer: undefined, isSubscribedT function TestToolMenu({user = USER_DEFAULT, network}: TestToolMenuProps) { const shouldUseStagingServer = user?.shouldUseStagingServer ?? ApiUtils.isUsingStagingApi(); - const styles = useThemeStyles(); return ( <> - - Test Preferences - - {/* Option to switch between staging and default api endpoints. This enables QA, internal testers and external devs to take advantage of sandbox environments for 3rd party services like Plaid and Onfido. This toggle is not rendered for internal devs as they make environment changes directly to the .env file. */} diff --git a/src/components/TestToolRow.tsx b/src/components/TestToolRow.tsx index 4ed1aa126002..f6ff1787b566 100644 --- a/src/components/TestToolRow.tsx +++ b/src/components/TestToolRow.tsx @@ -14,7 +14,7 @@ type TestToolRowProps = { function TestToolRow({title, children}: TestToolRowProps) { const styles = useThemeStyles(); return ( - + {title} diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index c6429747bf97..9e169ab2464a 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -265,11 +265,12 @@ function BaseTextInput( return ( <> void]; @@ -28,6 +29,7 @@ type UseArrowKeyFocusManager = [number, (index: number) => void]; * @param [config.isActive] – Whether the component is ready and should subscribe to KeyboardShortcut * @param [config.itemsPerRow] – The number of items per row. If provided, the arrow keys will move focus horizontally as well as vertically * @param [config.disableCyclicTraversal] – Whether to disable cyclic traversal of the list. If true, the arrow keys will have no effect when the first or last item is focused + * @param [config.disableHorizontalKeys] – Whether to disable the right/left keys */ export default function useArrowKeyFocusManager({ maxIndex, @@ -41,6 +43,7 @@ export default function useArrowKeyFocusManager({ isActive, itemsPerRow, disableCyclicTraversal = false, + disableHorizontalKeys = false, }: Config): UseArrowKeyFocusManager { const allowHorizontalArrowKeys = !!itemsPerRow; const [focusedIndex, setFocusedIndex] = useState(initialFocusedIndex); @@ -52,6 +55,14 @@ export default function useArrowKeyFocusManager({ [isActive, shouldExcludeTextAreaNodes], ); + const horizontalArrowConfig = useMemo( + () => ({ + excludedNodes: shouldExcludeTextAreaNodes ? ['TEXTAREA'] : [], + isActive: isActive && !disableHorizontalKeys, + }), + [isActive, shouldExcludeTextAreaNodes, disableHorizontalKeys], + ); + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => onFocusedIndexChange(focusedIndex), [focusedIndex]); @@ -155,7 +166,7 @@ export default function useArrowKeyFocusManager({ }); }, [allowHorizontalArrowKeys, disableCyclicTraversal, disabledIndexes, maxIndex]); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, arrowConfig); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, horizontalArrowConfig); const arrowRightCallback = useCallback(() => { if (maxIndex < 0 || !allowHorizontalArrowKeys) { @@ -182,7 +193,7 @@ export default function useArrowKeyFocusManager({ }); }, [allowHorizontalArrowKeys, disableCyclicTraversal, disabledIndexes, maxIndex]); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, arrowConfig); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, horizontalArrowConfig); // Note: you don't need to manually manage focusedIndex in the parent. setFocusedIndex is only exposed in case you want to reset focusedIndex or focus a specific item return [focusedIndex, setFocusedIndex]; diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index 382cce2c8b09..e753218e8406 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -16,7 +16,7 @@ function useMarkdownStyle(): MarkdownStyle { color: theme.link, }, h1: { - fontSize: variables.fontSizeh1, + fontSize: variables.fontSizeLarge, }, blockquote: { borderColor: theme.border, diff --git a/src/hooks/useReimbursementAccountStepFormSubmit.ts b/src/hooks/useReimbursementAccountStepFormSubmit.ts new file mode 100644 index 000000000000..1f4de6ed2677 --- /dev/null +++ b/src/hooks/useReimbursementAccountStepFormSubmit.ts @@ -0,0 +1,45 @@ +import {useCallback} from 'react'; +import * as FormActions from '@userActions/FormActions'; +import type {OnyxFormKeyWithoutDraft} from '@userActions/FormActions'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReimbursementAccountDraftValues} from '@src/types/onyx/ReimbursementAccountDraft'; +import type {SubStepProps} from './useSubStep/types'; + +type UseReimbursementAccountStepFormSubmitParams = Pick & { + formId?: OnyxFormKeyWithoutDraft; + fieldIds: Array; +}; + +/** + * Hook for handling submit method in ReimbursementAccount substeps. + * When user is in editing mode we should save values only when user confirm that + * @param formId - ID for particular form + * @param isEditing - if form is in editing mode + * @param onNext - callback + * @param fieldIds - field IDs for particular step + */ +export default function useReimbursementAccountStepFormSubmit({ + formId = ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, + isEditing, + onNext, + fieldIds, +}: UseReimbursementAccountStepFormSubmitParams) { + return useCallback( + (values: ReimbursementAccountDraftValues) => { + if (isEditing) { + const stepValues = fieldIds.reduce( + (acc, key) => ({ + ...acc, + [key]: values[key], + }), + {}, + ); + + FormActions.setDraftValues(formId, stepValues); + } + + onNext(); + }, + [isEditing, onNext, formId, fieldIds], + ); +} diff --git a/src/hooks/useSubStep/index.ts b/src/hooks/useSubStep/index.ts new file mode 100644 index 000000000000..ad4cf032858d --- /dev/null +++ b/src/hooks/useSubStep/index.ts @@ -0,0 +1,57 @@ +import {useCallback, useRef, useState} from 'react'; +import type {SubStepProps, UseSubStep} from './types'; + +/** + * This hook ensures uniform handling of components across different screens, enabling seamless integration and navigation through sub steps of the VBBA flow. + * @param bodyContent - array of components to display in particular step + * @param onFinished - callback triggered after finish last step + * @param startFrom - initial index for bodyContent array + */ +export default function useSubStep({bodyContent, onFinished, startFrom = 0}: UseSubStep) { + const [screenIndex, setScreenIndex] = useState(startFrom); + const isEditing = useRef(false); + + const prevScreen = useCallback(() => { + const prevScreenIndex = screenIndex - 1; + + if (prevScreenIndex < 0) { + return; + } + + setScreenIndex(prevScreenIndex); + }, [screenIndex]); + + const nextScreen = useCallback(() => { + if (isEditing.current) { + isEditing.current = false; + + setScreenIndex(bodyContent.length - 1); + + return; + } + + const nextScreenIndex = screenIndex + 1; + + if (nextScreenIndex === bodyContent.length) { + onFinished(); + } else { + setScreenIndex(nextScreenIndex); + } + }, [screenIndex, bodyContent.length, onFinished]); + + const moveTo = useCallback((step: number) => { + isEditing.current = true; + setScreenIndex(step); + }, []); + + const resetScreenIndex = useCallback(() => { + setScreenIndex(0); + }, []); + + const goToTheLastStep = useCallback(() => { + isEditing.current = false; + setScreenIndex(bodyContent.length - 1); + }, [bodyContent]); + + return {componentToRender: bodyContent[screenIndex], isEditing: isEditing.current, screenIndex, prevScreen, nextScreen, moveTo, resetScreenIndex, goToTheLastStep}; +} diff --git a/src/hooks/useSubStep/types.ts b/src/hooks/useSubStep/types.ts new file mode 100644 index 000000000000..ffdee5825197 --- /dev/null +++ b/src/hooks/useSubStep/types.ts @@ -0,0 +1,31 @@ +import type {ComponentType} from 'react'; + +type SubStepProps = { + /** value indicating whether user is editing one of the sub steps */ + isEditing: boolean; + + /** continues to next sub step */ + onNext: () => void; + + /** moves user to passed sub step */ + onMove: (step: number) => void; + + /** index of currently displayed sub step */ + screenIndex?: number; + + /** moves user to previous sub step */ + prevScreen?: () => void; +}; + +type UseSubStep = { + /** array of components that will become sub steps */ + bodyContent: Array>; + + /** called on last sub step */ + onFinished: () => void; + + /** index of initial sub step to display */ + startFrom?: number; +}; + +export type {SubStepProps, UseSubStep}; diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index 76d48158237b..70e66ab65a08 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -49,14 +49,13 @@ type ViolationsMap = Map; function useViolations(violations: TransactionViolation[]) { const violationsByField = useMemo((): ViolationsMap => { + const filteredViolations = violations.filter((violation) => violation.type === 'violation'); const violationGroups = new Map(); - - for (const violation of violations) { + for (const violation of filteredViolations) { const field = violationFields[violation.name]; const existingViolations = violationGroups.get(field) ?? []; violationGroups.set(field, [...existingViolations, violation]); } - return violationGroups ?? new Map(); }, [violations]); diff --git a/src/languages/en.ts b/src/languages/en.ts index f24b0e3e2438..c705541216ab 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -84,7 +84,6 @@ import type { ViolationsInvoiceMarkupParams, ViolationsMaxAgeParams, ViolationsMissingTagParams, - ViolationsOverAutoApprovalLimitParams, ViolationsOverCategoryLimitParams, ViolationsOverLimitParams, ViolationsPerDayLimitParams, @@ -156,7 +155,7 @@ export default { lastName: 'Last name', phone: 'Phone', phoneNumber: 'Phone number', - phoneNumberPlaceholder: '(xxx)xxx-xxxx', + phoneNumberPlaceholder: '(xxx) xxx-xxxx', email: 'Email', and: 'and', details: 'Details', @@ -192,6 +191,7 @@ export default { noPO: 'PO boxes and mail drop addresses are not allowed', city: 'City', state: 'State', + streetAddress: 'Street address', stateOrProvince: 'State / Province', country: 'Country', zip: 'Zip code', @@ -297,6 +297,7 @@ export default { tbd: 'TBD', selectCurrency: 'Select a currency', card: 'Card', + whyDoWeAskForThis: 'Why do we ask for this?', required: 'Required', showing: 'Showing', of: 'of', @@ -719,6 +720,10 @@ export default { subtitle: 'These details are used for travel and payments. They are never shown on your public profile.', }, }, + securityPage: { + title: 'Security options', + subtitle: 'Enable two-factor authentication to keep your account safe.', + }, shareCodePage: { title: 'Your code', subtitle: 'Invite members to Expensify by sharing your personal QR code or referral link.', @@ -1052,7 +1057,16 @@ export default { defaultPaymentMethod: 'Default', }, preferencesPage: { + appSection: { + title: 'App preferences', + subtitle: 'Customize your Expensify account.', + }, + testSection: { + title: 'Test preferences', + subtitle: 'Settings to help debug and test the app on staging.', + }, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Receive relevant feature updates and Expensify news', + muteAllSounds: 'Mute all sounds from Expensify', }, priorityModePage: { priorityMode: 'Priority mode', @@ -1275,8 +1289,15 @@ export default { return result; }, bankAccount: { + bankInfo: 'Bank info', + confirmBankInfo: 'Confirm bank info', + manuallyAdd: 'Manually add your bank account', + letsDoubleCheck: "Let's double check that everything looks right.", + accountEnding: 'Account ending in', + thisBankAccount: 'This bank account will be used for business payments on your workspace', accountNumber: 'Account number', routingNumber: 'Routing number', + chooseAnAccountBelow: 'Choose an account below', addBankAccount: 'Add bank account', chooseAnAccount: 'Choose an account', connectOnlineWithPlaid: 'Connect online with Plaid', @@ -1294,6 +1315,7 @@ export default { hasBeenThrottledError: 'There was an error adding your bank account. Please wait a few minutes and try again.', hasCurrencyError: 'Oops! It appears that your workspace currency is set to a different currency than USD. To proceed, please set it to USD and try again', error: { + youNeedToSelectAnOption: 'You need to select an option to proceed.', noBankAccountAvailable: 'Sorry, no bank account is available', noBankAccountSelected: 'Please choose an account', taxID: 'Please enter a valid tax ID number', @@ -1485,12 +1507,74 @@ export default { }, requestorStep: { headerTitle: 'Personal information', - subtitle: 'Please provide your personal information.', learnMore: 'Learn more', isMyDataSafe: 'Is my data safe?', - onFidoConditions: 'By continuing with the request to add this bank account, you confirm that you have read, understand and accept ', - isControllingOfficer: 'I am authorized to use my company bank account for business spend', - isControllingOfficerError: 'You must be a controlling officer with authorization to operate the business bank account.', + }, + personalInfoStep: { + personalInfo: 'Personal info', + enterYourLegalFirstAndLast: 'Enter your legal first and last name.', + legalFirstName: 'Legal first name', + legalLastName: 'Legal last name', + legalName: 'Legal name', + enterYourDateOfBirth: 'Enter your date of birth.', + enterTheLast4: 'Enter the last 4 of your SSN.', + dontWorry: "Don't worry, we don't do any personal credit checks!", + last4SSN: 'Last 4 Social Security Number', + enterYourAddress: 'Enter your address.', + address: 'Address', + letsDoubleCheck: "Let's double check that everything looks right.", + byAddingThisBankAccount: 'By adding this bank account, you confirm that you have read, understand and accept', + }, + businessInfoStep: { + businessInfo: 'Business info', + enterTheNameOfYourBusiness: 'Enter the name of your business.', + businessName: 'Legal business name', + enterYourCompanysTaxIdNumber: 'Enter your company’s Tax ID number.', + taxIDNumber: 'Tax ID number', + taxIDNumberPlaceholder: '9 digits', + enterYourCompanysWebsite: 'Enter your company’s website.', + companyWebsite: 'Company website', + enterYourCompanysPhoneNumber: 'Enter your company’s phone number.', + enterYourCompanysAddress: 'Enter your company’s address.', + selectYourCompanysType: 'Select your company’s type.', + companyType: 'Company type', + incorporationType: { + LLC: 'LLC', + CORPORATION: 'Corp', + PARTNERSHIP: 'Partnership', + COOPERATIVE: 'Cooperative', + SOLE_PROPRIETORSHIP: 'Sole proprietorship', + OTHER: 'Other', + }, + selectYourCompanysIncorporationDate: 'Select your company’s incorporation date.', + incorporationDate: 'Incorporation date', + incorporationDatePlaceholder: 'Start date (yyyy-mm-dd)', + incorporationState: 'Incorporation state', + pleaseSelectTheStateYourCompanyWasIncorporatedIn: 'Please select the state your company was incorporated in.', + letsDoubleCheck: "Let's double check that everything looks right.", + companyAddress: 'Company address', + listOfRestrictedBusinesses: 'list of restricted businesses', + confirmCompanyIsNot: 'I confirm that this company is not on the', + }, + beneficialOwnerInfoStep: { + doYouOwn25percent: 'Do you own 25% or more of', + doAnyIndividualOwn25percent: 'Do any individuals own 25% or more of', + areThereMoreIndividualsWhoOwn25percent: 'Are there more individuals who own 25% or more of', + regulationRequiresUsToVerifyTheIdentity: 'Regulation requires us to verify the identity of any individual that owns more than 25% of the company.', + companyOwner: 'Company owner', + enterLegalFirstAndLastName: 'Enter the legal first and last name of the owner.', + legalFirstName: 'Legal first name', + legalLastName: 'Legal last name', + enterTheDateOfBirthOfTheOwner: 'Enter the date of birth of the owner.', + enterTheLast4: 'Enter the last 4 of the owner’s SSN.', + last4SSN: 'Last 4 Social Security Number', + dontWorry: "Don't worry, we don't do any personal credit checks!", + enterTheOwnersAddress: 'Enter the owner’s address.', + letsDoubleCheck: 'Let’s double check that everything looks right.', + legalName: 'Legal name', + address: 'Address', + byAddingThisBankAccount: 'By adding this bank account, you confirm that you have read, understand and accept', + owners: 'Owners', }, validationStep: { headerTitle: 'Validate Bank Account', @@ -1523,6 +1607,34 @@ export default { certify: 'Must certify information is true and accurate', }, }, + completeVerificationStep: { + completeVerification: 'Complete verification', + confirmAgreements: 'Please confirm the agreements below.', + certifyTrueAndAccurate: 'I certify that the information provided is true and accurate', + certifyTrueAndAccurateError: 'Must certify information is true and accurate', + isAuthorizedToUseBankAccount: 'I am authorized to use my company bank account for business spend', + isAuthorizedToUseBankAccountError: 'You must be a controlling officer with authorization to operate the business bank account.', + termsAndConditions: 'terms and conditions', + }, + connectBankAccountStep: { + connectBankAccount: 'Connect bank account', + finishButtonText: 'Finish setup', + validateYourBankAccount: 'Validate your bank account', + validateButtonText: 'Validate', + validationInputLabel: 'Transaction', + maxAttemptsReached: 'Validation for this bank account has been disabled due to too many incorrect attempts.', + description: 'A day or two after you add your account to Expensify we send three (3) transactions to your account. They have a merchant line like "Expensify, Inc. Validation".', + descriptionCTA: 'Please enter each transaction amount in the fields below. Example: 1.51.', + reviewingInfo: "Thanks! We're reviewing your information, and will be in touch shortly. Please check your chat with Concierge ", + forNextSteps: ' for next steps to finish setting up your bank account.', + letsChatCTA: "Yes, let's chat", + letsChatText: 'Thanks for doing that. We need your help verifying a few pieces of information, but we can work this out quickly over chat. Ready?', + letsChatTitle: "Let's chat!", + enable2FATitle: 'Prevent fraud, enable two-factor authentication!', + enable2FAText: + 'We take your security seriously, so please set up two-factor authentication for your account now. That will allow us to dispute Expensify Card digital transactions, and will reduce your risk for fraud.', + secureYourAccount: 'Secure your account', + }, reimbursementAccountLoadingAnimation: { oneMoment: 'One moment', explanationLine: 'We’re taking a look at your information. You will be able to continue with next steps shortly.', @@ -1622,6 +1734,9 @@ export default { trackDistanceCopy: 'Set the per mile/km rate and choose a default unit to track.', trackDistanceRate: 'Rate', trackDistanceUnit: 'Unit', + trackDistanceChooseUnit: 'Choose a default unit to track.', + kilometers: 'Kilometers', + miles: 'Miles', unlockNextDayReimbursements: 'Unlock next-day reimbursements', captureNoVBACopyBeforeEmail: 'Ask your workspace members to forward receipts to ', captureNoVBACopyAfterEmail: ' and download the Expensify App to track cash expenses on the go.', @@ -1660,6 +1775,7 @@ export default { bookTravelWithConcierge: 'Book travel with Concierge', }, invite: { + member: 'Invite member', invitePeople: 'Invite new members', genericFailureMessage: 'An error occurred inviting the user to the workspace, please try again.', pleaseEnterValidLogin: `Please ensure the email or phone number is valid (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`, @@ -2141,7 +2257,7 @@ export default { allTagLevelsRequired: 'All tags required', autoReportedRejectedExpense: ({rejectReason, rejectedBy}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rejected this expense with the comment "${rejectReason}"`, billableExpense: 'Billable no longer valid', - cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Receipt required over ${amount}`, + cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams) => `Receipt required${formattedLimit ? ` over ${formattedLimit}` : ''}`, categoryOutOfPolicy: 'Category no longer valid', conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `Applied ${surcharge}% conversion surcharge`, customUnitOutOfPolicy: 'Unit no longer valid', @@ -2152,17 +2268,18 @@ export default { maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Date older than ${maxAge} days`, missingCategory: 'Missing category', missingComment: 'Description required for selected category', - missingTag: ({tagName}: ViolationsMissingTagParams) => `Missing ${tagName ?? 'tag'}`, + missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Missing ${tagName ?? 'tag'}`, modifiedAmount: 'Amount greater than scanned receipt', modifiedDate: 'Date differs from scanned receipt', nonExpensiworksExpense: 'Non-Expensiworks expense', - overAutoApprovalLimit: ({formattedLimitAmount}: ViolationsOverAutoApprovalLimitParams) => `Expense exceeds auto approval limit of ${formattedLimitAmount}`, - overCategoryLimit: ({categoryLimit}: ViolationsOverCategoryLimitParams) => `Amount over ${categoryLimit}/person category limit`, - overLimit: ({amount}: ViolationsOverLimitParams) => `Amount over ${amount}/person limit`, - overLimitAttendee: ({amount}: ViolationsOverLimitParams) => `Amount over ${amount}/person limit`, - perDayLimit: ({limit}: ViolationsPerDayLimitParams) => `Amount over daily ${limit}/person category limit`, + overAutoApprovalLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Expense exceeds auto approval limit of ${formattedLimit}`, + overCategoryLimit: ({formattedLimit}: ViolationsOverCategoryLimitParams) => `Amount over ${formattedLimit}/person category limit`, + overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, + overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, + perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Amount over daily ${formattedLimit}/person category limit`, receiptNotSmartScanned: 'Receipt not verified. Please confirm accuracy.', - receiptRequired: ({amount, category}: ViolationsReceiptRequiredParams) => `Receipt required over ${amount} ${category ? ' category limit' : ''}`, + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams = {}) => + `Receipt required${formattedLimit ? ` over ${formattedLimit}${category ? ' category limit' : ''}` : ''}`, rter: ({brokenBankConnection, email, isAdmin, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { if (brokenBankConnection) { return isAdmin diff --git a/src/languages/es.ts b/src/languages/es.ts index 3238e7fa94b0..5da1aa3a9dc6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -143,9 +143,9 @@ export default { continue: 'Continuar', firstName: 'Nombre', lastName: 'Apellidos', - phone: 'teléfono', + phone: 'Teléfono', phoneNumber: 'Número de teléfono', - phoneNumberPlaceholder: '(xxx)xxx-xxxx', + phoneNumberPlaceholder: '(xxx) xxx-xxxx', email: 'Email', and: 'y', details: 'Detalles', @@ -181,6 +181,7 @@ export default { noPO: 'No se aceptan apartados ni direcciones postales', city: 'Ciudad', state: 'Estado', + streetAddress: 'Dirección', stateOrProvince: 'Estado / Provincia', country: 'País', zip: 'Código postal', @@ -286,6 +287,7 @@ export default { tbd: 'Por determinar', selectCurrency: 'Selecciona una moneda', card: 'Tarjeta', + whyDoWeAskForThis: '¿Por qué pedimos esto?', required: 'Obligatorio', showing: 'Mostrando', of: 'de', @@ -713,6 +715,10 @@ export default { subtitle: 'Estos detalles se utilizan para viajes y pagos. Nunca se mostrarán en tu perfil público.', }, }, + securityPage: { + title: 'Opciones de seguridad', + subtitle: 'Activa la autenticación de dos factores para mantener tu cuenta segura.', + }, shareCodePage: { title: 'Tu código', subtitle: 'Invita a miembros a Expensify compartiendo tu código QR personal o enlace de invitación.', @@ -1049,7 +1055,16 @@ export default { defaultPaymentMethod: 'Predeterminado', }, preferencesPage: { + appSection: { + title: 'Preferencias de la aplicación', + subtitle: 'Personaliza tu cuenta de Expensify.', + }, + testSection: { + title: 'Preferencias para tests', + subtitle: 'Ajustes para ayudar a depurar y probar la aplicación en “staging”.', + }, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Recibir noticias sobre Expensify y actualizaciones del producto', + muteAllSounds: 'Silenciar todos los sonidos de Expensify', }, priorityModePage: { priorityMode: 'Modo prioridad', @@ -1290,8 +1305,15 @@ export default { return result; }, bankAccount: { + bankInfo: 'Información bancaria', + confirmBankInfo: 'Confirmar información bancaria', + manuallyAdd: 'Añadir manualmente tu cuenta bancaria', + letsDoubleCheck: 'Verifiquemos que todo esté correcto.', + accountEnding: 'Cuenta terminada en', + thisBankAccount: 'Esta cuenta bancaria se utilizará para pagos comerciales en tu espacio de trabajo', accountNumber: 'Número de cuenta', routingNumber: 'Número de ruta', + chooseAnAccountBelow: 'Elige una cuenta a continuación', addBankAccount: 'Añadir cuenta bancaria', chooseAnAccount: 'Elige una cuenta', connectOnlineWithPlaid: 'Conéctate a Plaid online', @@ -1311,6 +1333,7 @@ export default { hasCurrencyError: '¡Ups! Parece que la moneda de tu espacio de trabajo está configurada en una moneda diferente a USD. Para continuar, por favor configúrala en USD e inténtalo nuevamente.', error: { + youNeedToSelectAnOption: 'Debes seleccionar una opción para continuar.', noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible', noBankAccountSelected: 'Por favor, elige una cuenta bancaria', taxID: 'Por favor, introduce un número de identificación fiscal válido', @@ -1506,12 +1529,74 @@ export default { }, requestorStep: { headerTitle: 'Información personal', - subtitle: 'Dé más información sobre tí.', learnMore: 'Más información', isMyDataSafe: '¿Están seguros mis datos?', - onFidoConditions: 'Al continuar con la solicitud de añadir esta cuenta bancaria, confirma que ha leído, entiende y acepta ', - isControllingOfficer: 'Estoy autorizado a utilizar la cuenta bancaria de mi compañía para gastos de empresa', - isControllingOfficerError: 'Debe ser un oficial controlador con autorización para operar la cuenta bancaria de la compañía', + }, + personalInfoStep: { + personalInfo: 'Información Personal', + enterYourLegalFirstAndLast: 'Introduce tu nombre y apellidos', + legalFirstName: 'Nombre', + legalLastName: 'Apellidos', + legalName: 'Nombre legal', + enterYourDateOfBirth: 'Introduce tu fecha de nacimiento', + enterTheLast4: 'Introduce los últimos 4 dígitos de tu número de la seguridad social', + dontWorry: 'No te preocupes, no hacemos ninguna verificación de crédito', + last4SSN: 'Últimos 4 dígitos de tu número de la seguridad social', + enterYourAddress: 'Introduce tu dirección', + address: 'Dirección', + letsDoubleCheck: 'Revisemos que todo esté bien', + byAddingThisBankAccount: 'Añadiendo esta cuenta bancaria, confirmas que has leído, entendido y aceptado', + }, + businessInfoStep: { + businessInfo: 'Información de la empresa', + enterTheNameOfYourBusiness: 'Introduce el nombre de tu empresa.', + businessName: 'Nombre de la empresa', + enterYourCompanysTaxIdNumber: 'Introduce el número de identificación fiscal.', + taxIDNumber: 'Número de identificación fiscal', + taxIDNumberPlaceholder: '9 dígitos', + enterYourCompanysWebsite: 'Introduce la página web de tu empresa.', + companyWebsite: 'Página web de la empresa', + enterYourCompanysPhoneNumber: 'Introduce el número de teléfono de tu empresa.', + enterYourCompanysAddress: 'Introduce la dirección de tu empresa.', + selectYourCompanysType: 'Selecciona el tipo de empresa.', + companyType: 'Tipo de empresa', + incorporationType: { + LLC: 'SRL', + CORPORATION: 'Corporación', + PARTNERSHIP: 'Sociedad', + COOPERATIVE: 'Cooperativa', + SOLE_PROPRIETORSHIP: 'Empresa individual', + OTHER: 'Otros', + }, + selectYourCompanysIncorporationDate: 'Selecciona la fecha de constitución de la empresa.', + incorporationDate: 'Fecha de constitución', + incorporationDatePlaceholder: 'Fecha de inicio (yyyy-mm-dd)', + incorporationState: 'Estado en el que se constituyó', + pleaseSelectTheStateYourCompanyWasIncorporatedIn: 'Selecciona el estado en el que se constituyó la empresa.', + letsDoubleCheck: 'Verifiquemos que todo esté correcto', + companyAddress: 'Dirección de la empresa', + listOfRestrictedBusinesses: 'lista de negocios restringidos', + confirmCompanyIsNot: 'Confirmo que esta empresa no está en la', + }, + beneficialOwnerInfoStep: { + doYouOwn25percent: '¿Posees el 25% o más de', + doAnyIndividualOwn25percent: '¿Alguna persona posee el 25% o más de', + areThereMoreIndividualsWhoOwn25percent: '¿Hay más personas que posean el 25% o más de', + regulationRequiresUsToVerifyTheIdentity: 'La ley nos exige verificar la identidad de cualquier persona que posea más del 25% de la empresa.', + companyOwner: 'Dueño de la empresa', + enterLegalFirstAndLastName: 'Introduce el nombre y apellidos legales del dueño.', + legalFirstName: 'Nombre legal', + legalLastName: 'Apellidos legales', + enterTheDateOfBirthOfTheOwner: 'Introduce la fecha de nacimiento del dueño.', + enterTheLast4: 'Introduce los últimos 4 dígitos del número de la seguridad social del dueño.', + last4SSN: 'Últimos 4 dígitos del número de la seguridad social', + dontWorry: 'No te preocupes, ¡no realizamos verificaciones de crédito personales!', + enterTheOwnersAddress: 'Introduce la dirección del dueño.', + letsDoubleCheck: 'Vamos a verificar que todo esté correcto.', + legalName: 'Nombre legal', + address: 'Dirección', + byAddingThisBankAccount: 'Al añadir esta cuenta bancaria, confirmas que has leído, comprendido y aceptado', + owners: 'Dueños', }, validationStep: { headerTitle: 'Validar cuenta bancaria', @@ -1545,6 +1630,35 @@ export default { certify: 'Debe certificar que la información es verdadera y precisa', }, }, + completeVerificationStep: { + completeVerification: 'Completar la verificación', + confirmAgreements: 'Por favor, confirma los acuerdos siguientes.', + certifyTrueAndAccurate: 'Certifico que la información dada es verdadera y precisa', + certifyTrueAndAccurateError: 'Debe certificar que la información es verdadera y precisa', + isAuthorizedToUseBankAccount: 'Estoy autorizado para usar la cuenta bancaria de mi empresa para gastos de empresa', + isAuthorizedToUseBankAccountError: 'Debes ser el responsable oficial con autorización para operar la cuenta bancaria de la empresa.', + termsAndConditions: 'Términos y Condiciones', + }, + connectBankAccountStep: { + connectBankAccount: 'Conectar cuenta bancaria', + finishButtonText: 'Finalizar configuración', + validateYourBankAccount: 'Valida tu cuenta bancaria', + validateButtonText: 'Validar', + validationInputLabel: 'Transacción', + maxAttemptsReached: 'La validación de esta cuenta bancaria se ha desactivado debido a demasiados intentos incorrectos.', + description: + 'Un día o dos después de añadir tu cuenta a Expensify, te enviaremos tres (3) transacciones a tu cuenta. Tienen un nombre de comerciante similar a "Expensify, Inc. Validation".', + descriptionCTA: 'Introduce el importe de cada transacción en los campos siguientes. Ejemplo: 1.51.', + reviewingInfo: '¡Gracias! Estamos revisando tu información y nos comunicaremos contigo en breve. Consulta el chat con Concierge ', + forNextSteps: ' para conocer los próximos pasos para terminar de configurar tu cuenta bancaria.', + letsChatCTA: 'Sí, vamos a chatear', + letsChatText: 'Gracias. Necesitamos tu ayuda para verificar la información, pero podemos resolverlo rápidamente a través del chat. ¿Estás Listo?', + letsChatTitle: '¡Vamos a chatear!', + enable2FATitle: '¡Evita fraudes, activa la autenticación de dos factores!', + enable2FAText: + 'Tu seguridad es importante para nosotros. Por favor, configura ahora la autenticación de dos factores. Eso nos permitirá disputar las transacciones de la Tarjeta Expensify y reducirá tu riesgo de fraude.', + secureYourAccount: 'Asegura tu cuenta', + }, reimbursementAccountLoadingAnimation: { oneMoment: 'Un momento', explanationLine: 'Estamos verificando tu información y podrás continuar con los siguientes pasos en unos momentos.', @@ -1645,6 +1759,9 @@ export default { trackDistanceCopy: 'Configura la tarifa y unidad usadas para medir distancias.', trackDistanceRate: 'Tarifa', trackDistanceUnit: 'Unidad', + trackDistanceChooseUnit: 'Elija una unidad predeterminada para rastrear.', + kilometers: 'Kilómetros', + miles: 'Millas', unlockNextDayReimbursements: 'Desbloquea reembolsos diarios', captureNoVBACopyBeforeEmail: 'Pide a los miembros de tu espacio de trabajo que envíen recibos a ', captureNoVBACopyAfterEmail: ' y descarga la App de Expensify para controlar tus gastos en efectivo sobre la marcha.', @@ -1683,6 +1800,7 @@ export default { bookTravelWithConcierge: 'Reserva viajes con Concierge', }, invite: { + member: 'Invitar miembros', invitePeople: 'Invitar nuevos miembros', genericFailureMessage: 'Se produjo un error al invitar al usuario al espacio de trabajo. Vuelva a intentarlo..', pleaseEnterValidLogin: `Asegúrese de que el correo electrónico o el número de teléfono sean válidos (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, @@ -2628,9 +2746,9 @@ export default { allTagLevelsRequired: 'Todas las etiquetas son obligatorias', autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, billableExpense: 'La opción facturable ya no es válida', - cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para cantidades mayores de ${amount}`, + cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para cantidades mayores de ${formattedLimit}`, categoryOutOfPolicy: 'La categoría ya no es válida', - conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `${surcharge}% de recargo aplicado`, + conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams = {}) => `${surcharge}% de recargo aplicado`, customUnitOutOfPolicy: 'La unidad ya no es válida', duplicatedTransaction: 'Posible duplicado', fieldRequired: 'Los campos del informe son obligatorios', @@ -2639,17 +2757,19 @@ export default { maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Fecha de más de ${maxAge} días`, missingCategory: 'Falta categoría', missingComment: 'Descripción obligatoria para la categoría seleccionada', - missingTag: ({tagName}: ViolationsMissingTagParams) => `Falta ${tagName}`, + missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Falta ${tagName ?? 'etiqueta'}`, modifiedAmount: 'Importe superior al del recibo escaneado', modifiedDate: 'Fecha difiere del recibo escaneado', nonExpensiworksExpense: 'Gasto no proviene de Expensiworks', - overAutoApprovalLimit: ({formattedLimitAmount}: ViolationsOverAutoApprovalLimitParams) => `Importe supera el límite de aprobación automática de ${formattedLimitAmount}`, - overCategoryLimit: ({categoryLimit}: ViolationsOverCategoryLimitParams) => `Importe supera el límite para la categoría de ${categoryLimit}/persona`, - overLimit: ({amount}: ViolationsOverLimitParams) => `Importe supera el límite de ${amount}/persona`, - overLimitAttendee: ({amount}: ViolationsOverLimitParams) => `Importe supera el límite de ${amount}/persona`, - perDayLimit: ({limit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría de ${limit}/persona`, + overAutoApprovalLimit: ({formattedLimit}: ViolationsOverAutoApprovalLimitParams) => + `Importe supera el límite de aprobación automática${formattedLimit ? ` de ${formattedLimit}` : ''}`, + overCategoryLimit: ({formattedLimit}: ViolationsOverCategoryLimitParams) => `Importe supera el límite para la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma su exactitud', - receiptRequired: ({amount, category}: ViolationsReceiptRequiredParams) => `Recibo obligatorio para importes sobre ${category ? 'el limite de la categoría de ' : ''}${amount}`, + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams = {}) => + `Recibo obligatorio${formattedLimit ? ` para importes sobre ${category ? 'el limite de la categoría de ' : ''}${formattedLimit}` : ''}`, rter: ({brokenBankConnection, isAdmin, email, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { if (brokenBankConnection) { return isAdmin diff --git a/src/languages/types.ts b/src/languages/types.ts index c9442c6560a3..ca98e23a4c07 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -213,7 +213,7 @@ type WalletProgramParams = {walletProgram: string}; type ViolationsAutoReportedRejectedExpenseParams = {rejectedBy: string; rejectReason: string}; -type ViolationsCashExpenseWithNoReceiptParams = {amount: string}; +type ViolationsCashExpenseWithNoReceiptParams = {formattedLimit?: string}; type ViolationsConversionSurchargeParams = {surcharge?: number}; @@ -223,15 +223,15 @@ type ViolationsMaxAgeParams = {maxAge: number}; type ViolationsMissingTagParams = {tagName?: string}; -type ViolationsOverAutoApprovalLimitParams = {formattedLimitAmount: string}; +type ViolationsOverAutoApprovalLimitParams = {formattedLimit?: string}; -type ViolationsOverCategoryLimitParams = {categoryLimit: string}; +type ViolationsOverCategoryLimitParams = {formattedLimit?: string}; -type ViolationsOverLimitParams = {amount: string}; +type ViolationsOverLimitParams = {formattedLimit?: string}; -type ViolationsPerDayLimitParams = {limit: string}; +type ViolationsPerDayLimitParams = {formattedLimit?: string}; -type ViolationsReceiptRequiredParams = {amount: string; category?: string}; +type ViolationsReceiptRequiredParams = {formattedLimit?: string; category?: string}; type ViolationsRterParams = { brokenBankConnection: boolean; diff --git a/src/libs/API/parameters/AcceptACHContractForBankAccount.ts b/src/libs/API/parameters/AcceptACHContractForBankAccount.ts new file mode 100644 index 000000000000..cd9473038fb0 --- /dev/null +++ b/src/libs/API/parameters/AcceptACHContractForBankAccount.ts @@ -0,0 +1,5 @@ +import type {ACHContractStepProps} from '@src/types/onyx/ReimbursementAccountDraft'; + +type AcceptACHContractForBankAccount = ACHContractStepProps & {bankAccountID: number; canUseNewVbbaFlow?: boolean}; + +export default AcceptACHContractForBankAccount; diff --git a/src/libs/API/parameters/ConnectBankAccountManuallyParams.ts b/src/libs/API/parameters/ConnectBankAccountManuallyParams.ts index 4f166cfd3aa9..17a72588a1e2 100644 --- a/src/libs/API/parameters/ConnectBankAccountManuallyParams.ts +++ b/src/libs/API/parameters/ConnectBankAccountManuallyParams.ts @@ -3,5 +3,7 @@ type ConnectBankAccountManuallyParams = { accountNumber?: string; routingNumber?: string; plaidMask?: string; + canUseNewVbbaFlow?: boolean; + policyID?: string; }; export default ConnectBankAccountManuallyParams; diff --git a/src/libs/API/parameters/ConnectBankAccountWithPlaidParams.ts b/src/libs/API/parameters/ConnectBankAccountWithPlaidParams.ts index 63df9d280412..e41a3192420e 100644 --- a/src/libs/API/parameters/ConnectBankAccountWithPlaidParams.ts +++ b/src/libs/API/parameters/ConnectBankAccountWithPlaidParams.ts @@ -5,6 +5,8 @@ type ConnectBankAccountWithPlaidParams = { bank?: string; plaidAccountID: string; plaidAccessToken: string; + canUseNewVbbaFlow?: boolean; + policyID?: string; }; export default ConnectBankAccountWithPlaidParams; diff --git a/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts b/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts index d831609b2e0a..5cd4bff2b94b 100644 --- a/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts +++ b/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts @@ -7,6 +7,8 @@ type OpenReimbursementAccountPageParams = { stepToOpen: ReimbursementAccountStep; subStep: ReimbursementAccountSubStep; localCurrentStep: ReimbursementAccountStep; + policyID?: string; + canUseNewVbbaFlow?: boolean; }; export default OpenReimbursementAccountPageParams; diff --git a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts index 414c87ee8989..aad6b1d34685 100644 --- a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts @@ -1,5 +1,5 @@ import type {ACHContractStepProps} from '@src/types/onyx/ReimbursementAccountDraft'; -type UpdateBeneficialOwnersForBankAccountParams = ACHContractStepProps; +type UpdateBeneficialOwnersForBankAccountParams = ACHContractStepProps & {bankAccountID: number; canUseNewVbbaFlow?: boolean}; export default UpdateBeneficialOwnersForBankAccountParams; diff --git a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts index 7588039a9abf..a2dc4ab2e1d3 100644 --- a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts @@ -2,7 +2,7 @@ import type {BankAccountStepProps, CompanyStepProps, ReimbursementAccountProps} type BankAccountCompanyInformation = BankAccountStepProps & CompanyStepProps & ReimbursementAccountProps; -type UpdateCompanyInformationForBankAccountParams = BankAccountCompanyInformation & {policyID: string}; +type UpdateCompanyInformationForBankAccountParams = BankAccountCompanyInformation & {bankAccountID: number; canUseNewVbbaFlow?: boolean}; export default UpdateCompanyInformationForBankAccountParams; export type {BankAccountCompanyInformation}; diff --git a/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts b/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts index 4de2e462fc7a..b4ec55877e71 100644 --- a/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts @@ -1,5 +1,5 @@ import type {RequestorStepProps} from '@src/types/onyx/ReimbursementAccountDraft'; -type UpdatePersonalInformationForBankAccountParams = RequestorStepProps; +type UpdatePersonalInformationForBankAccountParams = RequestorStepProps & {bankAccountID: number; canUseNewVbbaFlow: boolean}; export default UpdatePersonalInformationForBankAccountParams; diff --git a/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts b/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts index 22bbd20c7308..010bcaa1e60a 100644 --- a/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts +++ b/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts @@ -1,6 +1,6 @@ type UpdateWorkspaceCustomUnitAndRateParams = { policyID: string; - lastModified: number; + lastModified?: string; customUnit: string; customUnitRate: string; }; diff --git a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts index 424cef92c08f..2104977e04d5 100644 --- a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts +++ b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts @@ -1,5 +1,6 @@ type VerifyIdentityForBankAccountParams = { bankAccountID: number; onfidoData: string; + canUseNewVbbaFlow?: boolean; }; export default VerifyIdentityForBankAccountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 4d784463c2f8..482c5e0336c4 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -139,3 +139,4 @@ export type {default as ReplaceReceiptParams} from './ReplaceReceiptParams'; export type {default as SubmitReportParams} from './SubmitReportParams'; export type {default as DetachReceiptParams} from './DetachReceiptParams'; export type {default as PayMoneyRequestParams} from './PayMoneyRequestParams'; +export type {default as AcceptACHContractForBankAccount} from './AcceptACHContractForBankAccount'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 4b383bacddaa..f5d99d8cf40e 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -141,6 +141,7 @@ const WRITE_COMMANDS = { DETACH_RECEIPT: 'DetachReceipt', PAY_MONEY_REQUEST_WITH_WALLET: 'PayMoneyRequestWithWallet', PAY_MONEY_REQUEST: 'PayMoneyRequest', + ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT: 'AcceptACHContractForBankAccount', } as const; type WriteCommand = ValueOf; @@ -279,6 +280,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DETACH_RECEIPT]: Parameters.DetachReceiptParams; [WRITE_COMMANDS.PAY_MONEY_REQUEST_WITH_WALLET]: Parameters.PayMoneyRequestParams; [WRITE_COMMANDS.PAY_MONEY_REQUEST]: Parameters.PayMoneyRequestParams; + [WRITE_COMMANDS.ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT]: Parameters.AcceptACHContractForBankAccount; }; const READ_COMMANDS = { diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index cec9d1e09088..4098cbcd31fc 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -110,6 +110,21 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR }); } +/** + * Given an amount, convert it to a string for display in the UI. + * + * @param amount – should be a float. + * @param currency - IOU currency + */ +function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRENCY.USD): string { + const convertedAmount = amount / 100.0; + return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + style: 'currency', + currency, + minimumFractionDigits: getCurrencyDecimals(currency) + 1, + }); +} + /** * Checks if passed currency code is a valid currency based on currency list */ @@ -127,5 +142,6 @@ export { convertToBackendAmount, convertToFrontendAmount, convertToDisplayString, + convertAmountToDisplayString, isValidCurrencyCode, }; diff --git a/src/libs/DoInteractionTask/index.desktop.ts b/src/libs/DoInteractionTask/index.desktop.ts index 73b3cb19ec32..3b0340d68425 100644 --- a/src/libs/DoInteractionTask/index.desktop.ts +++ b/src/libs/DoInteractionTask/index.desktop.ts @@ -1,10 +1,10 @@ import {InteractionManager} from 'react-native'; +import type DoInteractionTask from './types'; // For desktop, we should call the callback after all interactions to prevent freezing. See more detail in https://github.com/Expensify/App/issues/28916 -function doInteractionTask(callback: () => void) { - return InteractionManager.runAfterInteractions(() => { +const doInteractionTask: DoInteractionTask = (callback) => + InteractionManager.runAfterInteractions(() => { callback(); }); -} export default doInteractionTask; diff --git a/src/libs/DoInteractionTask/index.ts b/src/libs/DoInteractionTask/index.ts index dffbb0562b98..0eadb8f7dbee 100644 --- a/src/libs/DoInteractionTask/index.ts +++ b/src/libs/DoInteractionTask/index.ts @@ -1,6 +1,8 @@ -function doInteractionTask(callback: () => void) { +import type DoInteractionTask from './types'; + +const doInteractionTask: DoInteractionTask = (callback) => { callback(); return null; -} +}; export default doInteractionTask; diff --git a/src/libs/DoInteractionTask/types.ts b/src/libs/DoInteractionTask/types.ts new file mode 100644 index 000000000000..3c1da4b5364d --- /dev/null +++ b/src/libs/DoInteractionTask/types.ts @@ -0,0 +1,5 @@ +import type {InteractionManager} from 'react-native'; + +type DoInteractionTask = (callback: () => void) => ReturnType | null; + +export default DoInteractionTask; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 8cfaa684917e..7db0cd4c3eb0 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -60,8 +60,8 @@ type OnyxDataWithErrors = { errors?: Errors | null; }; -function getLatestErrorMessage(onyxData: TOnyxData): Localize.MaybePhraseKey { - const errors = onyxData.errors ?? {}; +function getLatestErrorMessage(onyxData: TOnyxData | null): Localize.MaybePhraseKey { + const errors = onyxData?.errors ?? {}; if (Object.keys(errors).length === 0) { return ''; @@ -132,7 +132,7 @@ function getErrorsWithTranslationData(errors: Localize.MaybePhraseKey | Errors): * @param errors - An object containing current errors in the form * @param message - Message to assign to the inputID errors */ -function addErrorMessage(errors: Errors, inputID?: string, message?: TKey | Localize.MaybePhraseKey) { +function addErrorMessage(errors: Errors, inputID?: string | null, message?: TKey | Localize.MaybePhraseKey) { if (!message || !inputID) { return; } diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index ff86070395b3..da8a5b843ec1 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -34,10 +34,10 @@ function addLeadingZero(amount: string): string { /** * Calculate the length of the amount with leading zeroes */ -function calculateAmountLength(amount: string): number { +function calculateAmountLength(amount: string, decimals: number): number { const leadingZeroes = amount.match(/^0+/); const leadingZeroesLength = leadingZeroes?.[0]?.length ?? 0; - const absAmount = parseFloat((Number(stripCommaFromAmount(amount)) * 100).toFixed(2)).toString(); + const absAmount = parseFloat((Number(stripCommaFromAmount(amount)) * 10 ** decimals).toFixed(2)).toString(); if (/\D/.test(absAmount)) { return CONST.IOU.AMOUNT_MAX_LENGTH + 1; @@ -55,7 +55,7 @@ function validateAmount(amount: string, decimals: number): boolean { ? `^\\d+(,\\d*)*$` // Don't allow decimal point if decimals === 0 : `^\\d+(,\\d*)*(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point const decimalNumberRegex = new RegExp(regexString, 'i'); - return amount === '' || (decimalNumberRegex.test(amount) && calculateAmountLength(amount) <= CONST.IOU.AMOUNT_MAX_LENGTH); + return amount === '' || (decimalNumberRegex.test(amount) && calculateAmountLength(amount, decimals) <= CONST.IOU.AMOUNT_MAX_LENGTH); } /** diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index b0ec6d1f3a94..4723bbfd9b4e 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -99,7 +99,6 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CONFIRMATION]: () => require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType, - [SCREENS.MONEY_REQUEST.DATE]: () => require('../../../pages/iou/MoneyRequestDatePage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CATEGORY]: () => require('../../../pages/iou/MoneyRequestCategoryPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.MERCHANT]: () => require('../../../pages/iou/MoneyRequestMerchantPage').default as React.ComponentType, [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: () => require('../../../pages/AddPersonalBankAccountPage').default as React.ComponentType, @@ -238,7 +237,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../pages/settings/Profile/CustomStatus/SetDatePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../pages/settings/Profile/CustomStatus/SetTimePage').default as React.ComponentType, - [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE]: () => require('../../../pages/workspace/WorkspaceInvitePage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE_MESSAGE]: () => require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType, [SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType, 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 d96ad416832d..1936de564c37 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -3,7 +3,7 @@ import SCREENS from '@src/SCREENS'; const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = { [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY], - [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT], + [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], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index ab218adc3879..52e512d32c59 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -229,6 +229,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RATE_AND_UNIT]: { path: ROUTES.WORKSPACE_RATE_AND_UNIT.route, }, + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: { + path: ROUTES.WORKSPACE_RATE_AND_UNIT_RATE.route, + }, + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: { + path: ROUTES.WORKSPACE_RATE_AND_UNIT_UNIT.route, + }, [SCREENS.WORKSPACE.INVITE]: { path: ROUTES.WORKSPACE_INVITE.route, }, @@ -405,7 +411,6 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: ROUTES.MONEY_REQUEST_STEP_TAX_RATE.route, [SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route, [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route, - [SCREENS.MONEY_REQUEST.DATE]: ROUTES.MONEY_REQUEST_DATE.route, [SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route, [SCREENS.MONEY_REQUEST.CATEGORY]: ROUTES.MONEY_REQUEST_CATEGORY.route, [SCREENS.MONEY_REQUEST.MERCHANT]: ROUTES.MONEY_REQUEST_MERCHANT.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index bbdb03ab3df8..0bc574b12b15 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -140,7 +140,15 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: undefined; [SCREENS.WORKSPACE.CURRENCY]: undefined; [SCREENS.WORKSPACE.NAME]: undefined; - [SCREENS.WORKSPACE.RATE_AND_UNIT]: undefined; + [SCREENS.WORKSPACE.RATE_AND_UNIT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: { + policyID: string; + }; [SCREENS.WORKSPACE.INVITE]: { policyID: string; }; @@ -224,11 +232,12 @@ type MoneyRequestNavigatorParamList = { currency: string; backTo: string; }; - [SCREENS.MONEY_REQUEST.DATE]: { - iouType: string; + [SCREENS.MONEY_REQUEST.STEP_DATE]: { + action: ValueOf; + iouType: ValueOf; + transactionID: string; reportID: string; - field: string; - threadReportID: string; + backTo: string; }; [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: { action: ValueOf; @@ -338,7 +347,10 @@ type ReimbursementAccountNavigatorParamList = { }; type WalletStatementNavigatorParamList = { - [SCREENS.WALLET_STATEMENT_ROOT]: undefined; + [SCREENS.WALLET_STATEMENT_ROOT]: { + /** The statement year and month as one string, i.e. 202110 */ + yearMonth: string; + }; }; type FlagCommentNavigatorParamList = { diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts index 911e665f3ff4..f44b6802b540 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts @@ -42,7 +42,7 @@ function canUseBrowserNotifications(): Promise { * @param icon Path to icon * @param data extra data to attach to the notification */ -function push(title: string, body = '', icon: string | ImageSourcePropType = '', data: LocalNotificationData = {}, onClick: LocalNotificationClickHandler = () => {}) { +function push(title: string, body = '', icon: string | ImageSourcePropType = '', data: LocalNotificationData = {}, onClick: LocalNotificationClickHandler = () => {}, silent = false) { canUseBrowserNotifications().then((canUseNotifications) => { if (!canUseNotifications) { return; @@ -54,6 +54,7 @@ function push(title: string, body = '', icon: string | ImageSourcePropType = '', body, icon: String(icon), data, + silent, }); notificationCache[notificationID].onclick = () => { onClick(); @@ -104,7 +105,7 @@ export default { reportID: report.reportID, }; - push(title, body, icon, data, onClick); + push(title, body, icon, data, onClick, true); }, pushModifiedExpenseNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, usesIcon = false) { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 046a66a8d6e0..0515260378e0 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1756,7 +1756,7 @@ function getIOUConfirmationOptionsFromParticipants(participants: Participant[], * Build the options for the New Group view */ function getFilteredOptions( - reports: Record, + reports: OnyxCollection, personalDetails: OnyxEntry, betas: Beta[] = [], searchValue = '', @@ -1942,7 +1942,7 @@ function formatSectionsFromSearchTerm( searchTerm: string, selectedOptions: ReportUtils.OptionData[], filteredRecentReports: ReportUtils.OptionData[], - filteredPersonalDetails: PersonalDetails[], + filteredPersonalDetails: ReportUtils.OptionData[], maxOptionsSelected: boolean, indexOffset = 0, personalDetails: OnyxEntry = {}, @@ -2025,4 +2025,4 @@ export { transformedTaxRates, }; -export type {MemberForList}; +export type {MemberForList, CategorySection}; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 0575e297da0c..a3729ef81fe4 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -15,7 +15,9 @@ type UnitRate = {rate: number}; * These are policies that we can use to create reports with in NewDot. */ function getActivePolicies(policies: OnyxCollection): Policy[] | undefined { - return Object.values(policies ?? {}).filter((policy): policy is Policy => policy !== null && policy && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + return Object.values(policies ?? {}).filter( + (policy): policy is Policy => policy !== null && policy && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!policy.name && !!policy.id, + ); } /** diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 1aeb6e6e7343..3658fb53a722 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -73,6 +73,19 @@ Onyx.connect({ callback: (val) => (isNetworkOffline = val?.isOffline ?? false), }); +let currentUserAccountID: number | undefined; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + // When signed out, value is undefined + if (!value) { + return; + } + + currentUserAccountID = value.accountID; + }, +}); + let environmentURL: string; Environment.getEnvironmentURL().then((url: string) => (environmentURL = url)); @@ -117,6 +130,16 @@ function isWhisperAction(reportAction: OnyxEntry): boolean { return (reportAction?.whisperedToAccountIDs ?? []).length > 0; } +/** + * Checks whether the report action is a whisper targeting someone other than the current user. + */ +function isWhisperActionTargetedToOthers(reportAction: OnyxEntry): boolean { + if (!isWhisperAction(reportAction)) { + return false; + } + return !reportAction?.whisperedToAccountIDs?.includes(currentUserAccountID ?? 0); +} + function isReimbursementQueuedAction(reportAction: OnyxEntry) { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED; } @@ -370,6 +393,10 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry, key: return false; } + if (isWhisperActionTargetedToOthers(reportAction)) { + return false; + } + if (isPendingRemove(reportAction) && !reportAction.childVisibleActionCount) { return false; } @@ -864,6 +891,7 @@ export { isThreadParentMessage, isTransactionThread, isWhisperAction, + isWhisperActionTargetedToOthers, isReimbursementQueuedAction, shouldReportActionBeVisible, shouldHideNewMarker, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 26280f95447d..a2401ad926d3 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3634,7 +3634,10 @@ function isUnread(report: OnyxEntry): boolean { // lastVisibleActionCreated and lastReadTime are both datetime strings and can be compared directly const lastVisibleActionCreated = report.lastVisibleActionCreated ?? ''; const lastReadTime = report.lastReadTime ?? ''; - return lastReadTime < lastVisibleActionCreated; + const lastMentionedTime = report.lastMentionedTime ?? ''; + + // If the user was mentioned and the comment got deleted the lastMentionedTime will be more recent than the lastVisibleActionCreated + return lastReadTime < lastVisibleActionCreated || lastReadTime < lastMentionedTime; } function isIOUOwnedByCurrentUser(report: OnyxEntry, allReportsDict: OnyxCollection = null): boolean { diff --git a/src/libs/Sound/config/index.native.ts b/src/libs/Sound/config/index.native.ts new file mode 100644 index 000000000000..e948c1e189e8 --- /dev/null +++ b/src/libs/Sound/config/index.native.ts @@ -0,0 +1,3 @@ +const config = {prefix: ''}; + +export default config; diff --git a/src/libs/Sound/config/index.ts b/src/libs/Sound/config/index.ts new file mode 100644 index 000000000000..f58755750d9d --- /dev/null +++ b/src/libs/Sound/config/index.ts @@ -0,0 +1,3 @@ +const config = {prefix: '/sounds/'}; + +export default config; diff --git a/src/libs/Sound/index.ts b/src/libs/Sound/index.ts new file mode 100644 index 000000000000..4639887e831c --- /dev/null +++ b/src/libs/Sound/index.ts @@ -0,0 +1,71 @@ +import Onyx from 'react-native-onyx'; +import Sound from 'react-native-sound'; +import type {ValueOf} from 'type-fest'; +import ONYXKEYS from '@src/ONYXKEYS'; +import config from './config'; + +let isMuted = false; + +Onyx.connect({ + key: ONYXKEYS.USER, + callback: (val) => (isMuted = !!val?.isMutedAllSounds), +}); + +const SOUNDS = { + DONE: 'done', + SUCCESS: 'success', + ATTENTION: 'attention', + RECEIVE: 'receive', +} as const; + +/** + * Creates a version of the given function that, when called, queues the execution and ensures that + * calls are spaced out by at least the specified `minExecutionTime`, even if called more frequently. This allows + * for throttling frequent calls to a function, ensuring each is executed with a minimum `minExecutionTime` between calls. + * Each call returns a promise that resolves when the function call is executed, allowing for asynchronous handling. + */ +function withMinimalExecutionTime) => ReturnType>(func: F, minExecutionTime: number) { + const queue: Array<[() => ReturnType, (value?: unknown) => void]> = []; + let timerId: NodeJS.Timeout | null = null; + + function processQueue() { + if (queue.length > 0) { + const next = queue.shift(); + + if (!next) { + return; + } + + const [nextFunc, resolve] = next; + nextFunc(); + resolve(); + timerId = setTimeout(processQueue, minExecutionTime); + } else { + timerId = null; + } + } + + return function (...args: Parameters) { + return new Promise((resolve) => { + queue.push([() => func(...args), resolve]); + + if (!timerId) { + // If the timer isn't running, start processing the queue + processQueue(); + } + }); + }; +} + +const playSound = (soundFile: ValueOf) => { + const sound = new Sound(`${config.prefix}${soundFile}.mp3`, Sound.MAIN_BUNDLE, (error) => { + if (error || isMuted) { + return; + } + + sound.play(); + }); +}; + +export {SOUNDS}; +export default withMinimalExecutionTime(playSound, 300); diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 7ee7d6c4f048..e52075ccba06 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -5,9 +5,9 @@ import isDate from 'lodash/isDate'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxFormValuesFields} from '@components/Form/types'; import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; -import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import * as CardUtils from './CardUtils'; import DateUtils from './DateUtils'; import * as LoginUtils from './LoginUtils'; @@ -74,8 +74,12 @@ function isValidPastDate(date: string | Date): boolean { /** * Used to validate a value that is "required". + * @param value - field value */ -function isRequiredFulfilled(value: string | Date | unknown[] | Record | null): boolean { +function isRequiredFulfilled(value?: string | boolean | Date | unknown[] | Record | null): boolean { + if (!value) { + return false; + } if (typeof value === 'string') { return !StringUtils.isEmptyString(value); } @@ -88,18 +92,27 @@ function isRequiredFulfilled(value: string | Date | unknown[] | Record = {[P in K[number]]: string}; /** * Used to add requiredField error to the fields passed. + * @param values - all form values + * @param requiredFields - required fields for particular form */ -function getFieldRequiredErrors(values: OnyxCommon.Errors, requiredFields: string[]) { - const errors: OnyxCommon.Errors = {}; - requiredFields.forEach((fieldKey) => { - if (isRequiredFulfilled(values[fieldKey])) { +function getFieldRequiredErrors(values: T, requiredFields: K): GetFieldRequiredErrorsReturn { + const errors: GetFieldRequiredErrorsReturn = {} as GetFieldRequiredErrorsReturn; + + requiredFields.forEach((fieldKey: K[number]) => { + if (isRequiredFulfilled(values[fieldKey as keyof OnyxFormValuesFields])) { return; } + errors[fieldKey] = 'common.error.fieldRequired'; }); + return errors; } diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 992449267934..0e14c1530259 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -93,22 +93,41 @@ const ViolationsUtils = { violation: TransactionViolation, translate: (phraseKey: TKey, ...phraseParameters: PhraseParameters>) => string, ): string { + const { + brokenBankConnection = false, + isAdmin = false, + email, + isTransactionOlderThan7Days = false, + member, + category, + rejectedBy, + rejectReason, + formattedLimit, + surcharge, + invoiceMarkup, + maxAge = 0, + tagName, + taxName, + } = violation.data ?? {}; + switch (violation.name) { case 'allTagLevelsRequired': return translate('violations.allTagLevelsRequired'); case 'autoReportedRejectedExpense': - return translate('violations.autoReportedRejectedExpense', { - rejectedBy: violation.data?.rejectedBy ?? '', - rejectReason: violation.data?.rejectReason ?? '', - }); + return rejectReason && rejectedBy + ? translate('violations.autoReportedRejectedExpense', { + rejectedBy, + rejectReason, + }) + : ''; case 'billableExpense': return translate('violations.billableExpense'); case 'cashExpenseWithNoReceipt': - return translate('violations.cashExpenseWithNoReceipt', {amount: violation.data?.amount ?? ''}); + return translate('violations.cashExpenseWithNoReceipt', {formattedLimit}); case 'categoryOutOfPolicy': return translate('violations.categoryOutOfPolicy'); case 'conversionSurcharge': - return translate('violations.conversionSurcharge', {surcharge: violation.data?.surcharge}); + return translate('violations.conversionSurcharge', {surcharge}); case 'customUnitOutOfPolicy': return translate('violations.customUnitOutOfPolicy'); case 'duplicatedTransaction': @@ -118,15 +137,15 @@ const ViolationsUtils = { case 'futureDate': return translate('violations.futureDate'); case 'invoiceMarkup': - return translate('violations.invoiceMarkup', {invoiceMarkup: violation.data?.invoiceMarkup}); + return translate('violations.invoiceMarkup', {invoiceMarkup}); case 'maxAge': - return translate('violations.maxAge', {maxAge: violation.data?.maxAge ?? 0}); + return translate('violations.maxAge', {maxAge}); case 'missingCategory': return translate('violations.missingCategory'); case 'missingComment': return translate('violations.missingComment'); case 'missingTag': - return translate('violations.missingTag', {tagName: violation.data?.tagName}); + return translate('violations.missingTag', {tagName}); case 'modifiedAmount': return translate('violations.modifiedAmount'); case 'modifiedDate': @@ -134,40 +153,37 @@ const ViolationsUtils = { case 'nonExpensiworksExpense': return translate('violations.nonExpensiworksExpense'); case 'overAutoApprovalLimit': - return translate('violations.overAutoApprovalLimit', {formattedLimitAmount: violation.data?.formattedLimitAmount ?? ''}); + return translate('violations.overAutoApprovalLimit', {formattedLimit}); case 'overCategoryLimit': - return translate('violations.overCategoryLimit', {categoryLimit: violation.data?.categoryLimit ?? ''}); + return translate('violations.overCategoryLimit', {formattedLimit}); case 'overLimit': - return translate('violations.overLimit', {amount: violation.data?.amount ?? ''}); + return translate('violations.overLimit', {formattedLimit}); case 'overLimitAttendee': - return translate('violations.overLimitAttendee', {amount: violation.data?.amount ?? ''}); + return translate('violations.overLimitAttendee', {formattedLimit}); case 'perDayLimit': - return translate('violations.perDayLimit', {limit: violation.data?.limit ?? ''}); + return translate('violations.perDayLimit', {formattedLimit}); case 'receiptNotSmartScanned': return translate('violations.receiptNotSmartScanned'); case 'receiptRequired': - return translate('violations.receiptRequired', { - amount: violation.data?.amount ?? '0', - category: violation.data?.category ?? '', - }); + return translate('violations.receiptRequired', {formattedLimit, category}); case 'rter': return translate('violations.rter', { - brokenBankConnection: violation.data?.brokenBankConnection ?? false, - isAdmin: violation.data?.isAdmin ?? false, - email: violation.data?.email, - isTransactionOlderThan7Days: !!violation.data?.isTransactionOlderThan7Days, - member: violation.data?.member, + brokenBankConnection, + isAdmin, + email, + isTransactionOlderThan7Days, + member, }); case 'smartscanFailed': return translate('violations.smartscanFailed'); case 'someTagLevelsRequired': return translate('violations.someTagLevelsRequired'); case 'tagOutOfPolicy': - return translate('violations.tagOutOfPolicy', {tagName: violation.data?.tagName}); + return translate('violations.tagOutOfPolicy', {tagName}); case 'taxAmountChanged': return translate('violations.taxAmountChanged'); case 'taxOutOfPolicy': - return translate('violations.taxOutOfPolicy', {taxName: violation.data?.taxName}); + return translate('violations.taxOutOfPolicy', {taxName}); case 'taxRateChanged': return translate('violations.taxRateChanged'); case 'taxRequired': diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 2c65804cb428..1342a869bf7b 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -7,13 +7,9 @@ import type { ConnectBankAccountWithPlaidParams, DeletePaymentBankAccountParams, OpenReimbursementAccountPageParams, - UpdateCompanyInformationForBankAccountParams, - UpdatePersonalInformationForBankAccountParams, ValidateBankAccountWithTransactionsParams, VerifyIdentityForBankAccountParams, } from '@libs/API/parameters'; -import type UpdateBeneficialOwnersForBankAccountParams from '@libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams'; -import type {BankAccountCompanyInformation} from '@libs/API/parameters/UpdateCompanyInformationForBankAccountParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -24,7 +20,7 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type PlaidBankAccount from '@src/types/onyx/PlaidBankAccount'; import type {BankAccountStep, BankAccountSubStep} from '@src/types/onyx/ReimbursementAccount'; -import type {OnfidoData} from '@src/types/onyx/ReimbursementAccountDraft'; +import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, OnfidoData, RequestorStepProps} from '@src/types/onyx/ReimbursementAccountDraft'; import type {OnyxData} from '@src/types/onyx/Request'; import * as ReimbursementAccount from './ReimbursementAccount'; @@ -47,6 +43,20 @@ type ReimbursementAccountStep = BankAccountStep | ''; type ReimbursementAccountSubStep = BankAccountSubStep | ''; +type BusinessAddress = { + addressStreet?: string; + addressCity?: string; + addressState?: string; + addressZipCode?: string; +}; + +type PersonalAddress = { + requestorAddressStreet?: string; + requestorAddressCity?: string; + requestorAddressState?: string; + requestorAddressZipCode?: string; +}; + function clearPlaid(): Promise { Onyx.set(ONYXKEYS.PLAID_LINK_TOKEN, ''); Onyx.set(ONYXKEYS.PLAID_CURRENT_EVENT, null); @@ -87,6 +97,7 @@ function clearPersonalBankAccount() { function clearOnfidoToken() { Onyx.merge(ONYXKEYS.ONFIDO_TOKEN, ''); + Onyx.merge(ONYXKEYS.ONFIDO_APPLICANT_ID, ''); } /** @@ -133,10 +144,14 @@ function getVBBADataForOnyx(currentStep?: BankAccountStep): OnyxData { }; } +function addBusinessWebsiteForDraft(websiteUrl: string) { + Onyx.merge(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, {website: websiteUrl}); +} + /** * Submit Bank Account step with Plaid data so php can perform some checks. */ -function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount) { +function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount, policyID: string) { const parameters: ConnectBankAccountWithPlaidParams = { bankAccountID, routingNumber: selectedPlaidBankAccount.routingNumber, @@ -144,6 +159,8 @@ function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAcc bank: selectedPlaidBankAccount.bankName, plaidAccountID: selectedPlaidBankAccount.plaidAccountID, plaidAccessToken: selectedPlaidBankAccount.plaidAccessToken, + canUseNewVbbaFlow: true, + policyID, }; API.write(WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_WITH_PLAID, parameters, getVBBADataForOnyx()); @@ -156,10 +173,10 @@ function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAcc */ function addPersonalBankAccount(account: PlaidBankAccount) { const parameters: AddPersonalBankAccountParams = { - addressName: account.addressName, + addressName: account.addressName ?? '', routingNumber: account.routingNumber, accountNumber: account.accountNumber, - isSavings: account.isSavings, + isSavings: account.isSavings ?? false, setupType: 'plaid', bank: account.bankName, plaidAccountID: account.plaidAccountID, @@ -234,9 +251,19 @@ function deletePaymentBankAccount(bankAccountID: number) { * Update the user's personal information on the bank account in database. * * This action is called by the requestor step in the Verified Bank Account flow + * @param bankAccountID - ID for bank account + * @param params - User personal data */ -function updatePersonalInformationForBankAccount(params: UpdatePersonalInformationForBankAccountParams) { - API.write(WRITE_COMMANDS.UPDATE_PERSONAL_INFORMATION_FOR_BANK_ACCOUNT, params, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.REQUESTOR)); +function updatePersonalInformationForBankAccount(bankAccountID: number, params: RequestorStepProps) { + API.write( + WRITE_COMMANDS.UPDATE_PERSONAL_INFORMATION_FOR_BANK_ACCOUNT, + { + ...params, + bankAccountID, + canUseNewVbbaFlow: true, + }, + getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.REQUESTOR), + ); } function validateBankAccount(bankAccountID: number, validateCode: string) { @@ -283,7 +310,14 @@ function clearReimbursementAccount() { Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, null); } -function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subStep: ReimbursementAccountSubStep, localCurrentStep: ReimbursementAccountStep) { +/** + * Function to display and fetch data for Reimbursement Account step + * @param stepToOpen - current step to open + * @param subStep - particular step + * @param localCurrentStep - last step on device + * @param policyID - policy ID + */ +function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subStep: ReimbursementAccountSubStep, localCurrentStep: ReimbursementAccountStep, policyID: string) { const onyxData: OnyxData = { optimisticData: [ { @@ -318,6 +352,8 @@ function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subS stepToOpen, subStep, localCurrentStep, + policyID, + canUseNewVbbaFlow: true, }; return API.read(READ_COMMANDS.OPEN_REIMBURSEMENT_ACCOUNT_PAGE, parameters, onyxData); @@ -325,30 +361,64 @@ function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subS /** * Updates the bank account in the database with the company step data + * @param params - Business step form data */ -function updateCompanyInformationForBankAccount(bankAccount: BankAccountCompanyInformation, policyID: string) { - const parameters: UpdateCompanyInformationForBankAccountParams = {...bankAccount, policyID}; +function updateCompanyInformationForBankAccount(bankAccountID: number, params: CompanyStepProps) { + API.write( + WRITE_COMMANDS.UPDATE_COMPANY_INFORMATION_FOR_BANK_ACCOUNT, + { + ...params, + bankAccountID, + canUseNewVbbaFlow: true, + }, + getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY), + ); +} - API.write(WRITE_COMMANDS.UPDATE_COMPANY_INFORMATION_FOR_BANK_ACCOUNT, parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY)); +/** + * Add beneficial owners for the bank account and verify the accuracy of the information provided + * @param params - Beneficial Owners step form params + */ +function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: BeneficialOwnersStepProps) { + API.write( + WRITE_COMMANDS.UPDATE_BENEFICIAL_OWNERS_FOR_BANK_ACCOUNT, + { + ...params, + bankAccountID, + canUseNewVbbaFlow: true, + }, + getVBBADataForOnyx(), + ); } /** - * Add beneficial owners for the bank account, accept the ACH terms and conditions and verify the accuracy of the information provided + * Accept the ACH terms and conditions and verify the accuracy of the information provided + * @param params - Verification step form params */ -function updateBeneficialOwnersForBankAccount(params: UpdateBeneficialOwnersForBankAccountParams) { - API.write(WRITE_COMMANDS.UPDATE_BENEFICIAL_OWNERS_FOR_BANK_ACCOUNT, params, getVBBADataForOnyx()); +function acceptACHContractForBankAccount(bankAccountID: number, params: ACHContractStepProps) { + API.write( + WRITE_COMMANDS.ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT, + { + ...params, + bankAccountID, + canUseNewVbbaFlow: true, + }, + getVBBADataForOnyx(), + ); } /** * Create the bank account with manually entered data. - * + * @param plaidMask - scheme for Plaid account number */ -function connectBankAccountManually(bankAccountID: number, accountNumber?: string, routingNumber?: string, plaidMask?: string) { +function connectBankAccountManually(bankAccountID: number, accountNumber?: string, routingNumber?: string, plaidMask?: string, policyID?: string) { const parameters: ConnectBankAccountManuallyParams = { bankAccountID, accountNumber, routingNumber, plaidMask, + canUseNewVbbaFlow: true, + policyID, }; API.write(WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_MANUALLY, parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT)); @@ -361,6 +431,7 @@ function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoD const parameters: VerifyIdentityForBankAccountParams = { bankAccountID, onfidoData: JSON.stringify(onfidoData), + canUseNewVbbaFlow: true, }; API.write(WRITE_COMMANDS.VERIFY_IDENTITY_FOR_BANK_ACCOUNT, parameters, getVBBADataForOnyx()); @@ -421,6 +492,8 @@ function setReimbursementAccountLoading(isLoading: boolean) { } export { + acceptACHContractForBankAccount, + addBusinessWebsiteForDraft, addPersonalBankAccount, clearOnfidoToken, clearPersonalBankAccount, @@ -443,3 +516,5 @@ export { verifyIdentityForBankAccount, setReimbursementAccountLoading, }; + +export type {BusinessAddress, PersonalAddress}; diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts index ed612a757f4b..0d2d3e867dbf 100644 --- a/src/libs/actions/FormActions.ts +++ b/src/libs/actions/FormActions.ts @@ -33,4 +33,5 @@ function clearDraftValues(formID: OnyxFormKeyWithoutDraft) { Onyx.set(FormUtils.getDraftKey(formID), null); } +export type {OnyxFormKeyWithoutDraft}; export {setDraftValues, setErrorFields, setErrors, clearErrors, clearErrorFields, setIsLoading, clearDraftValues}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index d7ac6f3c9361..7fca6614f1a1 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -269,8 +269,8 @@ function setMoneyRequestAmount_temporaryForRefactor(transactionID: string, amoun } // eslint-disable-next-line @typescript-eslint/naming-convention -function setMoneyRequestCreated_temporaryForRefactor(transactionID: string, created: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {created}); +function setMoneyRequestCreated(transactionID: string, created: string, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {created}); } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -1128,7 +1128,7 @@ function getUpdateMoneyRequestParams( }); } - // Optimistically modify the transaction + // Optimistically modify the transaction and the transaction thread optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, @@ -1140,6 +1140,14 @@ function getUpdateMoneyRequestParams( }, }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastActorAccountID: updatedReportAction.actorAccountID, + }, + }); + if (isScanning && ('amount' in transactionChanges || 'currency' in transactionChanges)) { optimisticData.push( { @@ -1237,6 +1245,13 @@ function getUpdateMoneyRequestParams( }); } + // Reset the transaction thread to its original state + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: transactionThread, + }); + return { params, onyxData: {optimisticData, successData, failureData}, @@ -3712,10 +3727,6 @@ function setMoneyRequestAmount(amount: number) { Onyx.merge(ONYXKEYS.IOU, {amount}); } -function setMoneyRequestCreated(created: string) { - Onyx.merge(ONYXKEYS.IOU, {created}); -} - function setMoneyRequestCurrency(currency: string) { Onyx.merge(ONYXKEYS.IOU, {currency}); } @@ -3858,7 +3869,7 @@ export { setMoneyRequestAmount_temporaryForRefactor, setMoneyRequestBillable_temporaryForRefactor, setMoneyRequestCategory_temporaryForRefactor, - setMoneyRequestCreated_temporaryForRefactor, + setMoneyRequestCreated, setMoneyRequestCurrency_temporaryForRefactor, setMoneyRequestDescription, setMoneyRequestOriginalCurrency_temporaryForRefactor, @@ -3869,7 +3880,6 @@ export { setMoneyRequestAmount, setMoneyRequestBillable, setMoneyRequestCategory, - setMoneyRequestCreated, setMoneyRequestCurrency, setMoneyRequestId, setMoneyRequestMerchant, diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 498ce6918509..d492f77fcdce 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -49,7 +49,7 @@ import type { Transaction, } from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {CustomUnit} from '@src/types/onyx/Policy'; +import type {Attributes, CustomUnit, Rate, Unit} from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -82,6 +82,13 @@ type OptimisticCustomUnits = { type PoliciesRecord = Record>; +type NewCustomUnit = { + customUnitID: string; + name: string; + attributes: Attributes; + rates: Rate; +}; + const allPolicies: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, @@ -116,6 +123,13 @@ Onyx.connect({ }, }); +let allReports: OnyxCollection = null; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => (allReports = value), +}); + let allPolicyMembers: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_MEMBERS, @@ -218,7 +232,7 @@ function hasActiveFreePolicy(policies: Array> | PoliciesRecord /** * Delete the workspace */ -function deleteWorkspace(policyID: string, reports: Report[], policyName: string) { +function deleteWorkspace(policyID: string, policyName: string) { if (!allPolicies) { return; } @@ -247,7 +261,9 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string : []), ]; - reports.forEach(({reportID, ownerAccountID}) => { + const reportsToArchive = Object.values(allReports ?? {}).filter((report) => report?.policyID === policyID && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report))); + reportsToArchive.forEach((report) => { + const {reportID, ownerAccountID} = report ?? {}; optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -292,7 +308,8 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string }, ]; - reports.forEach(({reportID, stateNum, statusNum, hasDraft, oldPolicyName}) => { + reportsToArchive.forEach((report) => { + const {reportID, stateNum, statusNum, hasDraft, oldPolicyName} = report ?? {}; failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -401,6 +418,7 @@ function removeOptimisticAnnounceRoomMembers(policyID: string, accountIDs: numbe /** * Remove the passed members from the policy employeeList + * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details */ function removeMembers(accountIDs: number[], policyID: string) { // In case user selects only themselves (admin), their email will be filtered out and the members @@ -618,6 +636,7 @@ function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: I /** * Adds members to the specified workspace/policyID + * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details */ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, policyID: string) { const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}` as const; @@ -945,7 +964,7 @@ function hideWorkspaceAlertMessage(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {alertMessage: ''}); } -function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit, lastModified: number) { +function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: NewCustomUnit, lastModified?: string) { if (!currentCustomUnit.customUnitID || !newCustomUnit?.customUnitID || !newCustomUnit.rates?.customUnitRateID) { return; } @@ -959,7 +978,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C [newCustomUnit.customUnitID]: { ...newCustomUnit, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { ...newCustomUnit.rates, errors: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -982,7 +1001,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C pendingAction: null, errors: null, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { pendingAction: null, }, }, @@ -1001,7 +1020,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C [currentCustomUnit.customUnitID]: { customUnitID: currentCustomUnit.customUnitID, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { ...currentCustomUnit.rates, errors: ErrorUtils.getMicroSecondOnyxError('workspace.reimburse.updateCustomUnitError'), }, @@ -1460,6 +1479,22 @@ function openWorkspaceReimburseView(policyID: string) { API.read(READ_COMMANDS.OPEN_WORKSPACE_REIMBURSE_VIEW, params, {successData, failureData}); } +function setPolicyIDForReimburseView(policyID: string) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {policyID, rate: null, unit: null}); +} + +function clearOnyxDataForReimburseView() { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, null); +} + +function setRateForReimburseView(rate: string) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {rate}); +} + +function setUnitForReimburseView(unit: Unit) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {unit}); +} + /** * Returns the accountIDs of the members of the policy whose data is passed in the parameters */ @@ -2009,6 +2044,10 @@ export { clearAddMemberError, clearDeleteWorkspaceError, openWorkspaceReimburseView, + setPolicyIDForReimburseView, + clearOnyxDataForReimburseView, + setRateForReimburseView, + setUnitForReimburseView, generateDefaultWorkspaceName, updateGeneralSettings, clearWorkspaceGeneralSettingsErrors, diff --git a/src/libs/actions/ReimbursementAccount/index.js b/src/libs/actions/ReimbursementAccount/index.js index 217cacf921a6..12b5b940a0f2 100644 --- a/src/libs/actions/ReimbursementAccount/index.js +++ b/src/libs/actions/ReimbursementAccount/index.js @@ -12,7 +12,7 @@ export {setBankAccountFormValidationErrors, setPersonalBankAccountFormValidation * - CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL to ask them to enter their accountNumber and routingNumber * - CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID to ask them to login to their bank via Plaid * - * @param {String} subStep + * @param {String | null} subStep * @returns {Promise} */ function setBankAccountSubStep(subStep) { diff --git a/src/libs/actions/ReimbursementAccount/navigation.js b/src/libs/actions/ReimbursementAccount/navigation.js index 0ea09465d795..6c82561c16ee 100644 --- a/src/libs/actions/ReimbursementAccount/navigation.js +++ b/src/libs/actions/ReimbursementAccount/navigation.js @@ -7,10 +7,9 @@ import ROUTES from '@src/ROUTES'; * Navigate to a specific step in the VBA flow * * @param {String} stepID - * @param {Object} newAchData */ -function goToWithdrawalAccountSetupStep(stepID, newAchData) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {...newAchData, currentStep: stepID}}); +function goToWithdrawalAccountSetupStep(stepID) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {currentStep: stepID}}); } /** diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js index 3110c059d2fc..962800fb2e55 100644 --- a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js @@ -43,6 +43,11 @@ function resetFreePlanBankAccount(bankAccountID, session) { key: ONYXKEYS.ONFIDO_TOKEN, value: '', }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.ONFIDO_APPLICANT_ID, + value: '', + }, { onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.PLAID_DATA, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 3fbe9cf86e15..b02d27daf03f 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2367,7 +2367,9 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record) { completedTaskReportActionID: completedTaskReportAction.reportActionID, }; + playSound(SOUNDS.SUCCESS); API.write(WRITE_COMMANDS.COMPLETE_TASK, parameters, {optimisticData, successData, failureData}); } diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 8f2f01cde3ac..1d9af01f2fa0 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -106,7 +106,7 @@ function saveWaypoint(transactionID: string, index: string, waypoint: RecentWayp } } -function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft: boolean): Promise { +function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft: boolean) { // Index comes from the route params and is a string const index = Number(currentIndex); const existingWaypoints = transaction?.comment?.waypoints ?? {}; @@ -115,7 +115,7 @@ function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft: const waypointValues = Object.values(existingWaypoints); const removed = waypointValues.splice(index, 1); if (removed.length === 0) { - return Promise.resolve(); + return; } const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? {}); @@ -164,9 +164,10 @@ function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft: }; } if (isDraft) { - return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); + return; } - return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, newTransaction); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, newTransaction); } function getOnyxDataForRouteRequest(transactionID: string, isDraft = false): OnyxData { diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 09cc6e49e6cc..75c61d7fab5f 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -28,6 +28,7 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as Pusher from '@libs/Pusher/pusher'; import PusherUtils from '@libs/PusherUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -37,6 +38,7 @@ import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails'; import type {Status} from '@src/types/onyx/PersonalDetails'; import type ReportAction from '@src/types/onyx/ReportAction'; +import type {OriginalMessage} from '@src/types/onyx/ReportAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import * as Link from './Link'; @@ -458,7 +460,7 @@ function isBlockedFromConcierge(blockedFromConciergeNVP: OnyxEntry { - if (!update.shouldNotify) { + if (!update.shouldNotify && !update.shouldShowPushNotification) { return; } @@ -469,6 +471,101 @@ function triggerNotifications(onyxUpdates: OnyxServerUpdate[]) { }); } +const isChannelMuted = (reportId: string) => + new Promise((resolve) => { + const connectionId = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${reportId}`, + callback: (report) => { + Onyx.disconnect(connectionId); + + resolve( + !report?.notificationPreference || + report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE || + report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + ); + }, + }); + }); + +function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { + const reportActionsOnly = pushJSON.filter((update) => update.key.includes('reportActions_')); + // "reportActions_5134363522480668" -> "5134363522480668" + const reportIDs = reportActionsOnly.map((value) => value.key.split('_')[1]); + + Promise.all(reportIDs.map((reportID) => isChannelMuted(reportID))) + .then((muted) => muted.every((isMuted) => isMuted)) + .then((isSoundMuted) => { + if (isSoundMuted) { + return; + } + + try { + const flatten = reportActionsOnly.flatMap((update) => { + const value = update.value as OnyxCollection; + + if (!value) { + return []; + } + + return Object.values(value); + }) as ReportAction[]; + + for (const data of flatten) { + // Someone completes a task + if (data.actionName === 'TASKCOMPLETED') { + return playSound(SOUNDS.SUCCESS); + } + } + + const types = flatten.map((data) => data?.originalMessage).filter(Boolean) as OriginalMessage[]; + + for (const message of types) { + // someone sent money + if ('IOUDetails' in message) { + return playSound(SOUNDS.SUCCESS); + } + + // mention user + if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + return playSound(SOUNDS.ATTENTION); + } + + // mention @here + if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + return playSound(SOUNDS.ATTENTION); + } + + // assign a task + if ('taskReportID' in message) { + return playSound(SOUNDS.ATTENTION); + } + + // request money + if ('IOUTransactionID' in message) { + return playSound(SOUNDS.ATTENTION); + } + + // Someone completes a money request + if ('IOUReportID' in message) { + return playSound(SOUNDS.SUCCESS); + } + + // plain message + if ('html' in message) { + return playSound(SOUNDS.RECEIVE); + } + } + } catch (e) { + let errorMessage = String(e); + if (e instanceof Error) { + errorMessage = e.message; + } + + Log.client(`Unexpected error occurred while parsing the data to play a sound: ${errorMessage}`); + } + }); +} + /** * Handles the newest events from Pusher where a single mega multipleEvents contains * an array of singular events all in one event @@ -514,8 +611,10 @@ function subscribeToUserEvents() { }); // Handles Onyx updates coming from Pusher through the mega multipleEvents. - PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON: OnyxServerUpdate[]) => - SequentialQueue.getCurrentRequest().then(() => { + PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON: OnyxServerUpdate[]) => { + playSoundForMessageType(pushJSON); + + return SequentialQueue.getCurrentRequest().then(() => { // If we don't have the currentUserAccountID (user is logged out) we don't want to update Onyx with data from Pusher if (currentUserAccountID === -1) { return; @@ -528,8 +627,8 @@ function subscribeToUserEvents() { // Return a promise when Onyx is done updating so that the OnyxUpdatesManager can properly apply all // the onyx updates in order return onyxUpdatePromise; - }), - ); + }); + }); } /** @@ -613,6 +712,10 @@ function clearUserErrorMessage() { Onyx.merge(ONYXKEYS.USER, {error: ''}); } +function setMuteAllSounds(isMutedAllSounds: boolean) { + Onyx.merge(ONYXKEYS.USER, {isMutedAllSounds}); +} + /** * Clear the data about a screen share request from Onyx. */ @@ -880,6 +983,7 @@ export { subscribeToUserEvents, updatePreferredSkinTone, setShouldUseStagingServer, + setMuteAllSounds, clearUserErrorMessage, updateFrequentlyUsedEmojis, joinScreenShare, diff --git a/src/pages/EditRequestCreatedPage.js b/src/pages/EditRequestCreatedPage.js deleted file mode 100644 index 2b5a8abaa349..000000000000 --- a/src/pages/EditRequestCreatedPage.js +++ /dev/null @@ -1,55 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import DatePicker from '@components/DatePicker'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const propTypes = { - /** Transaction defailt created value */ - defaultCreated: PropTypes.string.isRequired, - - /** Callback to fire when the Save button is pressed */ - onSubmit: PropTypes.func.isRequired, -}; - -function EditRequestCreatedPage({defaultCreated, onSubmit}) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - return ( - - - - - - - ); -} - -EditRequestCreatedPage.propTypes = propTypes; -EditRequestCreatedPage.displayName = 'EditRequestCreatedPage'; - -export default EditRequestCreatedPage; diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 777321fc2068..aa22439dee70 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -21,7 +21,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import EditRequestAmountPage from './EditRequestAmountPage'; import EditRequestCategoryPage from './EditRequestCategoryPage'; -import EditRequestCreatedPage from './EditRequestCreatedPage'; import EditRequestDistancePage from './EditRequestDistancePage'; import EditRequestMerchantPage from './EditRequestMerchantPage'; import EditRequestReceiptPage from './EditRequestReceiptPage'; @@ -129,19 +128,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p [transaction, report, policy, policyTags, policyCategories], ); - const saveCreated = useCallback( - ({created: newCreated}) => { - // If the value hasn't changed, don't request to save changes on the server and just close the modal - if (newCreated === TransactionUtils.getCreated(transaction)) { - Navigation.dismissModal(); - return; - } - IOU.updateMoneyRequestDate(transaction.transactionID, report.reportID, newCreated, policy, policyTags, policyCategories); - Navigation.dismissModal(); - }, - [transaction, report, policy, policyTags, policyCategories], - ); - const saveMerchant = useCallback( ({merchant: newMerchant}) => { const newTrimmedMerchant = newMerchant.trim(); @@ -190,15 +176,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p [transactionCategory, transaction.transactionID, report.reportID, policy, policyTags, policyCategories], ); - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) { - return ( - - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { return ( - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { return ( ; /** All of the personal details for everyone */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), + personalDetails: OnyxEntry; - /** All reports shared with the user */ - reports: PropTypes.objectOf(reportPropTypes), + betas: OnyxEntry; /** An object that holds data about which referral banners have been dismissed */ - dismissedReferralBanners: PropTypes.objectOf(PropTypes.bool), - - ...windowDimensionsPropTypes, - - ...withLocalizePropTypes, + dismissedReferralBanners: DismissedReferralBanners; /** Whether we are searching for reports in the server */ - isSearchingForReports: PropTypes.bool, + isSearchingForReports: OnyxEntry; }; -const defaultProps = { - betas: [], - dismissedReferralBanners: {}, - personalDetails: {}, - reports: {}, - isSearchingForReports: false, +type NewChatPageProps = NewChatPageWithOnyxProps & { + isGroupChat: boolean; }; -const excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE); +const excludedGroupEmails = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE); -function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, isSearchingForReports, dismissedReferralBanners}) { +function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingForReports, dismissedReferralBanners}: NewChatPageProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); const [searchTerm, setSearchTerm] = useState(''); - const [filteredRecentReports, setFilteredRecentReports] = useState([]); - const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); - const [filteredUserToInvite, setFilteredUserToInvite] = useState(); - const [selectedOptions, setSelectedOptions] = useState([]); + const [filteredRecentReports, setFilteredRecentReports] = useState([]); + const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); + const [filteredUserToInvite, setFilteredUserToInvite] = useState(); + const [selectedOptions, setSelectedOptions] = useState([]); const {isOffline} = useNetwork(); const {isSmallScreenWidth} = useWindowDimensions(); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); @@ -76,16 +66,18 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i Boolean(filteredUserToInvite), searchTerm.trim(), maxParticipantsReached, - _.some(selectedOptions, (participant) => participant.searchText.toLowerCase().includes(searchTerm.trim().toLowerCase())), + selectedOptions.some((participant) => participant?.searchText?.toLowerCase().includes(searchTerm.trim().toLowerCase())), ); + const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails); - const sections = useMemo(() => { - const sectionsList = []; + const sections = useMemo((): OptionsListUtils.CategorySection[] => { + const sectionsList: OptionsListUtils.CategorySection[] = []; let indexOffset = 0; const formatResults = OptionsListUtils.formatSectionsFromSearchTerm(searchTerm, selectedOptions, filteredRecentReports, filteredPersonalDetails, maxParticipantsReached, indexOffset); sectionsList.push(formatResults.section); + indexOffset = formatResults.newIndexOffset; if (maxParticipantsReached) { @@ -95,7 +87,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i sectionsList.push({ title: translate('common.recents'), data: filteredRecentReports, - shouldShow: !_.isEmpty(filteredRecentReports), + shouldShow: filteredRecentReports.length > 0, indexOffset, }); indexOffset += filteredRecentReports.length; @@ -103,7 +95,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i sectionsList.push({ title: translate('common.contacts'), data: filteredPersonalDetails, - shouldShow: !_.isEmpty(filteredPersonalDetails), + shouldShow: filteredPersonalDetails.length > 0, indexOffset, }); indexOffset += filteredPersonalDetails.length; @@ -122,15 +114,14 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i /** * Removes a selected option from list if already selected. If not already selected add this option to the list. - * @param {Object} option */ - const toggleOption = (option) => { - const isOptionInList = _.some(selectedOptions, (selectedOption) => selectedOption.login === option.login); + const toggleOption = (option: OptionData) => { + const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); let newSelectedOptions; if (isOptionInList) { - newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); + newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.login !== option.login); } else { newSelectedOptions = [...selectedOptions, option]; } @@ -142,7 +133,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i } = OptionsListUtils.getFilteredOptions( reports, personalDetails, - betas, + betas ?? [], searchTerm, newSelectedOptions, isGroupChat ? excludedGroupEmails : [], @@ -166,10 +157,11 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i /** * Creates a new 1:1 chat with the option and the current user, * or navigates to the existing chat if one with those participants already exists. - * - * @param {Object} option */ - const createChat = (option) => { + const createChat = (option: OptionData) => { + if (!option.login) { + return; + } Report.navigateToAndOpenReport([option.login]); }; @@ -178,10 +170,12 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i * or navigates to the existing chat if one with those participants already exists. */ const createGroup = () => { - const logins = _.pluck(selectedOptions, 'login'); + const logins = selectedOptions.map((option) => option.login).filter((login): login is string => typeof login === 'string'); + if (logins.length < 1) { return; } + Report.navigateToAndOpenReport(logins); }; @@ -193,7 +187,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i } = OptionsListUtils.getFilteredOptions( reports, personalDetails, - betas, + betas ?? [], searchTerm, selectedOptions, isGroupChat ? excludedGroupEmails : [], @@ -223,6 +217,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i if (!interactionTask) { return; } + interactionTask.cancel(); }; }, []); @@ -251,11 +246,12 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i behavior="padding" // Offset is needed as KeyboardAvoidingView in nested inside of TabNavigator instead of wrapping whole screen. // This is because when wrapping whole screen the screen was freezing when changing Tabs. - keyboardVerticalOffset={variables.contentHeaderHeight + insets.top + variables.tabSelectorButtonHeight + variables.tabSelectorButtonPadding} + keyboardVerticalOffset={variables.contentHeaderHeight + (insets?.top ?? 0) + variables.tabSelectorButtonHeight + variables.tabSelectorButtonPadding} > 0 ? safeAreaPaddingBottomStyle : {}]}> data.dismissedReferralBanners || {}, - }, - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - betas: { - key: ONYXKEYS.BETAS, - }, - isSearchingForReports: { - key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, - initWithStoredValues: false, - }, - }), -)(NewChatPage); +export default withOnyx({ + dismissedReferralBanners: { + key: ONYXKEYS.ACCOUNT, + selector: (data) => data?.dismissedReferralBanners ?? {}, + }, + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + betas: { + key: ONYXKEYS.BETAS, + }, + isSearchingForReports: { + key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, + initWithStoredValues: false, + }, +})(NewChatPage); diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx index eb3dd00ff802..aa02dfddd44c 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx @@ -22,6 +22,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PrivateNotesNavigatorParamList} from '@libs/Navigation/types'; import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; +import type {WithReportAndPrivateNotesOrNotFoundProps} from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import * as ReportActions from '@userActions/Report'; import CONST from '@src/CONST'; @@ -36,7 +37,8 @@ type PrivateNotesEditPageOnyxProps = { personalDetailsList: OnyxCollection; }; -type PrivateNotesEditPageProps = PrivateNotesEditPageOnyxProps & +type PrivateNotesEditPageProps = WithReportAndPrivateNotesOrNotFoundProps & + PrivateNotesEditPageOnyxProps & StackScreenProps & { /** The report currently being looked at */ report: Report; diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.tsx b/src/pages/PrivateNotes/PrivateNotesListPage.tsx index d7fb1f6497be..97ebc7dee2fb 100644 --- a/src/pages/PrivateNotes/PrivateNotesListPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesListPage.tsx @@ -1,7 +1,7 @@ import React, {useMemo} from 'react'; import {ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -10,24 +10,23 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import type {WithReportAndPrivateNotesOrNotFoundProps} from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetails, Report, Session} from '@src/types/onyx'; +import type {PersonalDetails, Report} from '@src/types/onyx'; type PrivateNotesListPageOnyxProps = { /** All of the personal details for everyone */ personalDetailsList: OnyxCollection; - - /** Session info for the currently logged in user. */ - session: OnyxEntry; }; -type PrivateNotesListPageProps = PrivateNotesListPageOnyxProps & { - /** The report currently being looked at */ - report: Report; -}; +type PrivateNotesListPageProps = WithReportAndPrivateNotesOrNotFoundProps & + PrivateNotesListPageOnyxProps & { + /** The report currently being looked at */ + report: Report; + }; type NoteListItem = { title: string; @@ -101,8 +100,5 @@ export default withReportAndPrivateNotesOrNotFound('privateNotes.title')( personalDetailsList: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, - session: { - key: ONYXKEYS.SESSION, - }, })(PrivateNotesListPage), ); diff --git a/src/pages/ReimbursementAccount/ACHContractStep.js b/src/pages/ReimbursementAccount/ACHContractStep.js index 625a29ddc130..f2c2e5af85de 100644 --- a/src/pages/ReimbursementAccount/ACHContractStep.js +++ b/src/pages/ReimbursementAccount/ACHContractStep.js @@ -1,294 +1,16 @@ -import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useState} from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; -import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import TextLink from '@components/TextLink'; -import withLocalize from '@components/withLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import * as BankAccounts from '@userActions/BankAccounts'; -import * as FormActions from '@userActions/FormActions'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import IdentityForm from './IdentityForm'; -import StepPropTypes from './StepPropTypes'; +import React from 'react'; +import CompleteVerification from './CompleteVerification/CompleteVerification'; const propTypes = { - ...StepPropTypes, - - /** Name of the company */ - companyName: PropTypes.string.isRequired, + /** Goes to the previous step */ + onBackButtonPress: PropTypes.func.isRequired, }; -function ACHContractStep(props) { - const styles = useThemeStyles(); - const [beneficialOwners, setBeneficialOwners] = useState(() => - lodashGet(props.reimbursementAccountDraft, 'beneficialOwners', lodashGet(props.reimbursementAccount, 'achData.beneficialOwners', [])), - ); - - /** - * @param {Object} values - input values passed by the Form component - * @returns {Object} - */ - const validate = (values) => { - const errors = {}; - - const errorKeys = { - street: 'address', - city: 'addressCity', - state: 'addressState', - }; - const requiredFields = ['firstName', 'lastName', 'dob', 'ssnLast4', 'street', 'city', 'zipCode', 'state']; - if (values.hasOtherBeneficialOwners) { - _.each(beneficialOwners, (ownerKey) => { - // eslint-disable-next-line rulesdir/prefer-early-return - _.each(requiredFields, (inputKey) => { - if (!ValidationUtils.isRequiredFulfilled(values[`beneficialOwner_${ownerKey}_${inputKey}`])) { - const errorKey = errorKeys[inputKey] || inputKey; - errors[`beneficialOwner_${ownerKey}_${inputKey}`] = `bankAccount.error.${errorKey}`; - } - }); - - if (values[`beneficialOwner_${ownerKey}_dob`]) { - if (!ValidationUtils.meetsMinimumAgeRequirement(values[`beneficialOwner_${ownerKey}_dob`])) { - errors[`beneficialOwner_${ownerKey}_dob`] = 'bankAccount.error.age'; - } else if (!ValidationUtils.meetsMaximumAgeRequirement(values[`beneficialOwner_${ownerKey}_dob`])) { - errors[`beneficialOwner_${ownerKey}_dob`] = 'bankAccount.error.dob'; - } - } - - if (values[`beneficialOwner_${ownerKey}_ssnLast4`] && !ValidationUtils.isValidSSNLastFour(values[`beneficialOwner_${ownerKey}_ssnLast4`])) { - errors[`beneficialOwner_${ownerKey}_ssnLast4`] = 'bankAccount.error.ssnLast4'; - } - - if (values[`beneficialOwner_${ownerKey}_street`] && !ValidationUtils.isValidAddress(values[`beneficialOwner_${ownerKey}_street`])) { - errors[`beneficialOwner_${ownerKey}_street`] = 'bankAccount.error.addressStreet'; - } - - if (values[`beneficialOwner_${ownerKey}_zipCode`] && !ValidationUtils.isValidZipCode(values[`beneficialOwner_${ownerKey}_zipCode`])) { - errors[`beneficialOwner_${ownerKey}_zipCode`] = 'bankAccount.error.zipCode'; - } - }); - } - - if (!ValidationUtils.isRequiredFulfilled(values.acceptTermsAndConditions)) { - errors.acceptTermsAndConditions = 'common.error.acceptTerms'; - } - - if (!ValidationUtils.isRequiredFulfilled(values.certifyTrueInformation)) { - errors.certifyTrueInformation = 'beneficialOwnersStep.error.certify'; - } - - return errors; - }; - - /** - * @param {Number} ownerKey - ID connected to the beneficial owner identity form - */ - const removeBeneficialOwner = (ownerKey) => { - setBeneficialOwners((previousBeneficialOwners) => { - const newBeneficialOwners = _.without(previousBeneficialOwners, ownerKey); - FormActions.setDraftValues(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {beneficialOwners: newBeneficialOwners}); - return newBeneficialOwners; - }); - }; - - const addBeneficialOwner = () => { - // Each beneficial owner is assigned a unique key that will connect it to an Identity Form. - // That way we can dynamically render each Identity Form based on which keys are present in the beneficial owners array. - setBeneficialOwners((previousBeneficialOwners) => { - const newBeneficialOwners = [...previousBeneficialOwners, Str.guid()]; - FormActions.setDraftValues(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {beneficialOwners: newBeneficialOwners}); - return newBeneficialOwners; - }); - }; - - /** - * @param {Boolean} ownsMoreThan25Percent - * @returns {Boolean} - */ - const canAddMoreBeneficialOwners = (ownsMoreThan25Percent) => _.size(beneficialOwners) < 3 || (_.size(beneficialOwners) === 3 && !ownsMoreThan25Percent); - - /** - * @param {Object} values - object containing form input values - */ - const submit = (values) => { - const bankAccountID = lodashGet(props.reimbursementAccount, 'achData.bankAccountID') || 0; - - const updatedBeneficialOwners = !values.hasOtherBeneficialOwners - ? [] - : _.map(beneficialOwners, (ownerKey) => ({ - firstName: lodashGet(values, `beneficialOwner_${ownerKey}_firstName`), - lastName: lodashGet(values, `beneficialOwner_${ownerKey}_lastName`), - dob: lodashGet(values, `beneficialOwner_${ownerKey}_dob`), - ssnLast4: lodashGet(values, `beneficialOwner_${ownerKey}_ssnLast4`), - street: lodashGet(values, `beneficialOwner_${ownerKey}_street`), - city: lodashGet(values, `beneficialOwner_${ownerKey}_city`), - state: lodashGet(values, `beneficialOwner_${ownerKey}_state`), - zipCode: lodashGet(values, `beneficialOwner_${ownerKey}_zipCode`), - })); - - BankAccounts.updateBeneficialOwnersForBankAccount({ - ownsMoreThan25Percent: values.ownsMoreThan25Percent, - hasOtherBeneficialOwners: values.hasOtherBeneficialOwners, - acceptTermsAndConditions: values.acceptTermsAndConditions, - certifyTrueInformation: values.certifyTrueInformation, - beneficialOwners: JSON.stringify(updatedBeneficialOwners), - bankAccountID, - }); - }; - - return ( - - - - {({inputValues}) => ( - <> - - {props.translate('beneficialOwnersStep.checkAllThatApply')} - - ( - - {props.translate('beneficialOwnersStep.iOwnMoreThan25Percent')} - {props.companyName} - - )} - // eslint-disable-next-line rulesdir/prefer-early-return - onValueChange={(ownsMoreThan25Percent) => { - if (ownsMoreThan25Percent && beneficialOwners.length > 3) { - // If the user owns more than 25% of the company, then there can only be a maximum of 3 other beneficial owners who owns more than 25%. - // We have to remove the 4th beneficial owner if the checkbox is checked. - setBeneficialOwners((previousBeneficialOwners) => previousBeneficialOwners.slice(0, -1)); - } - }} - defaultValue={props.getDefaultStateForField('ownsMoreThan25Percent', false)} - shouldSaveDraft - /> - ( - - {props.translate('beneficialOwnersStep.someoneOwnsMoreThan25Percent')} - {props.companyName} - - )} - // eslint-disable-next-line rulesdir/prefer-early-return - onValueChange={(hasOtherBeneficialOwners) => { - if (hasOtherBeneficialOwners && beneficialOwners.length === 0) { - addBeneficialOwner(); - } - }} - defaultValue={props.getDefaultStateForField('hasOtherBeneficialOwners', false)} - shouldSaveDraft - /> - {Boolean(inputValues.hasOtherBeneficialOwners) && ( - - {_.map(beneficialOwners, (ownerKey, index) => ( - - {props.translate('beneficialOwnersStep.additionalOwner')} - - {beneficialOwners.length > 1 && ( - removeBeneficialOwner(ownerKey)}>{props.translate('beneficialOwnersStep.removeOwner')} - )} - - ))} - {canAddMoreBeneficialOwners(inputValues.ownsMoreThan25Percent) && ( - - {props.translate('beneficialOwnersStep.addAnotherIndividual')} - {props.companyName} - - )} - - )} - {props.translate('beneficialOwnersStep.agreement')} - ( - - {props.translate('common.iAcceptThe')} - {`${props.translate('beneficialOwnersStep.termsAndConditions')}`} - - )} - defaultValue={props.getDefaultStateForField('acceptTermsAndConditions', false)} - shouldSaveDraft - /> - {props.translate('beneficialOwnersStep.certifyTrueAndAccurate')}} - defaultValue={props.getDefaultStateForField('certifyTrueInformation', false)} - shouldSaveDraft - /> - - )} - - - ); +function ACHContractStep({onBackButtonPress}) { + return ; } ACHContractStep.propTypes = propTypes; ACHContractStep.displayName = 'ACHContractStep'; -export default withLocalize(ACHContractStep); +export default ACHContractStep; diff --git a/src/pages/ReimbursementAccount/AddressForm.js b/src/pages/ReimbursementAccount/AddressForm.js index d09b03d9007f..16055265ddcc 100644 --- a/src/pages/ReimbursementAccount/AddressForm.js +++ b/src/pages/ReimbursementAccount/AddressForm.js @@ -102,7 +102,7 @@ function AddressForm(props) { inputID={props.inputKeys.street} shouldSaveDraft={props.shouldSaveDraft} label={props.translate(props.streetTranslationKey)} - containerStyles={[styles.mt4]} + containerStyles={[styles.mt6]} value={props.values.street} defaultValue={props.defaultValues.street} onInputChange={props.onFieldChange} @@ -124,10 +124,10 @@ function AddressForm(props) { defaultValue={props.defaultValues.city} onChangeText={(value) => props.onFieldChange({city: value})} errorText={props.errors.city ? 'bankAccount.error.addressCity' : ''} - containerStyles={[styles.mt4]} + containerStyles={[styles.mt6]} /> - + ); diff --git a/src/pages/ReimbursementAccount/BankAccountManualStep.js b/src/pages/ReimbursementAccount/BankAccountManualStep.js deleted file mode 100644 index 8283631e6936..000000000000 --- a/src/pages/ReimbursementAccount/BankAccountManualStep.js +++ /dev/null @@ -1,146 +0,0 @@ -import lodashGet from 'lodash/get'; -import React, {useCallback} from 'react'; -import _ from 'underscore'; -import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; -import TextLink from '@components/TextLink'; -import {withLocalizePropTypes} from '@components/withLocalize'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import shouldDelayFocus from '@libs/shouldDelayFocus'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import * as BankAccounts from '@userActions/BankAccounts'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ExampleCheck from './ExampleCheck'; -import StepPropTypes from './StepPropTypes'; - -const propTypes = { - ..._.omit(StepPropTypes, _.keys(withLocalizePropTypes)), -}; - -function BankAccountManualStep(props) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {reimbursementAccount, reimbursementAccountDraft} = props; - - const shouldDisableInputs = Boolean(lodashGet(reimbursementAccount, 'achData.bankAccountID')); - - /** - * @param {Object} values - form input values passed by the Form component - * @returns {Object} - */ - const validate = useCallback( - (values) => { - const requiredFields = ['routingNumber', 'accountNumber']; - const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields); - const routingNumber = values.routingNumber && values.routingNumber.trim(); - - if ( - values.accountNumber && - !CONST.BANK_ACCOUNT.REGEX.US_ACCOUNT_NUMBER.test(values.accountNumber.trim()) && - !(shouldDisableInputs && CONST.BANK_ACCOUNT.REGEX.MASKED_US_ACCOUNT_NUMBER.test(values.accountNumber.trim())) - ) { - errors.accountNumber = 'bankAccount.error.accountNumber'; - } else if (values.accountNumber && values.accountNumber === routingNumber) { - errors.accountNumber = translate('bankAccount.error.routingAndAccountNumberCannotBeSame'); - } - if (routingNumber && (!CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test(routingNumber) || !ValidationUtils.isValidRoutingNumber(routingNumber))) { - errors.routingNumber = 'bankAccount.error.routingNumber'; - } - if (!values.acceptTerms) { - errors.acceptTerms = 'common.error.acceptTerms'; - } - - return errors; - }, - [translate, shouldDisableInputs], - ); - - const submit = useCallback( - (values) => { - BankAccounts.connectBankAccountManually( - lodashGet(reimbursementAccount, 'achData.bankAccountID') || 0, - values.accountNumber, - values.routingNumber, - lodashGet(reimbursementAccountDraft, ['plaidMask']), - ); - }, - [reimbursementAccount, reimbursementAccountDraft], - ); - - return ( - - - - {translate('bankAccount.checkHelpLine')} - - - - ( - - {translate('common.iAcceptThe')} - {translate('common.expensifyTermsOfService')} - - )} - defaultValue={props.getDefaultStateForField('acceptTerms', false)} - shouldSaveDraft - /> - - - ); -} - -BankAccountManualStep.propTypes = propTypes; -BankAccountManualStep.displayName = 'BankAccountManualStep'; -export default BankAccountManualStep; diff --git a/src/pages/ReimbursementAccount/BankAccountPlaidStep.js b/src/pages/ReimbursementAccount/BankAccountPlaidStep.js deleted file mode 100644 index 99375fe66ad8..000000000000 --- a/src/pages/ReimbursementAccount/BankAccountPlaidStep.js +++ /dev/null @@ -1,156 +0,0 @@ -import {useIsFocused} from '@react-navigation/native'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect} from 'react'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import AddPlaidBankAccount from '@components/AddPlaidBankAccount'; -import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import TextLink from '@components/TextLink'; -import withLocalize from '@components/withLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import * as BankAccounts from '@userActions/BankAccounts'; -import * as ReimbursementAccount from '@userActions/ReimbursementAccount'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import * as PlaidDataProps from './plaidDataPropTypes'; -import StepPropTypes from './StepPropTypes'; - -const propTypes = { - ...StepPropTypes, - - /** Contains plaid data */ - plaidData: PlaidDataProps.plaidDataPropTypes, - - /** The OAuth URI + stateID needed to re-initialize the PlaidLink after the user logs into their bank */ - receivedRedirectURI: PropTypes.string, - - /** During the OAuth flow we need to use the plaidLink token that we initially connected with */ - plaidLinkOAuthToken: PropTypes.string, -}; - -const defaultProps = { - plaidData: PlaidDataProps.plaidDataDefaultProps, - receivedRedirectURI: null, - plaidLinkOAuthToken: '', -}; - -function BankAccountPlaidStep(props) { - const styles = useThemeStyles(); - const {plaidData, receivedRedirectURI, plaidLinkOAuthToken, reimbursementAccount, reimbursementAccountDraft, onBackButtonPress, getDefaultStateForField, translate} = props; - const isFocused = useIsFocused(); - - const validate = useCallback((values) => { - const errorFields = {}; - if (!values.acceptTerms) { - errorFields.acceptTerms = 'common.error.acceptTerms'; - } - - return errorFields; - }, []); - - useEffect(() => { - const plaidBankAccounts = lodashGet(plaidData, 'bankAccounts') || []; - if (isFocused || plaidBankAccounts.length) { - return; - } - BankAccounts.setBankAccountSubStep(null); - }, [isFocused, plaidData]); - - const submit = useCallback(() => { - const selectedPlaidBankAccount = _.findWhere(lodashGet(plaidData, 'bankAccounts', []), { - plaidAccountID: lodashGet(reimbursementAccountDraft, 'plaidAccountID', ''), - }); - - const bankAccountData = { - routingNumber: selectedPlaidBankAccount.routingNumber, - accountNumber: selectedPlaidBankAccount.accountNumber, - plaidMask: selectedPlaidBankAccount.mask, - isSavings: selectedPlaidBankAccount.isSavings, - bankName: lodashGet(plaidData, 'bankName') || '', - plaidAccountID: selectedPlaidBankAccount.plaidAccountID, - plaidAccessToken: lodashGet(plaidData, 'plaidAccessToken') || '', - }; - ReimbursementAccount.updateReimbursementAccountDraft(bankAccountData); - - const bankAccountID = lodashGet(reimbursementAccount, 'achData.bankAccountID') || 0; - BankAccounts.connectBankAccountWithPlaid(bankAccountID, bankAccountData); - }, [reimbursementAccount, reimbursementAccountDraft, plaidData]); - - const bankAccountID = lodashGet(reimbursementAccount, 'achData.bankAccountID') || 0; - const selectedPlaidAccountID = lodashGet(reimbursementAccountDraft, 'plaidAccountID', ''); - - return ( - - - - { - ReimbursementAccount.updateReimbursementAccountDraft({plaidAccountID}); - }} - plaidData={plaidData} - onExitPlaid={() => BankAccounts.setBankAccountSubStep(null)} - receivedRedirectURI={receivedRedirectURI} - plaidLinkOAuthToken={plaidLinkOAuthToken} - allowDebit - bankAccountID={bankAccountID} - selectedPlaidAccountID={selectedPlaidAccountID} - /> - {Boolean(selectedPlaidAccountID) && !_.isEmpty(lodashGet(plaidData, 'bankAccounts')) && ( - ( - - {translate('common.iAcceptThe')} - {translate('common.expensifyTermsOfService')} - - )} - defaultValue={getDefaultStateForField('acceptTerms', false)} - shouldSaveDraft - /> - )} - - - ); -} - -BankAccountPlaidStep.propTypes = propTypes; -BankAccountPlaidStep.defaultProps = defaultProps; -BankAccountPlaidStep.displayName = 'BankAccountPlaidStep'; -export default compose( - withLocalize, - withOnyx({ - plaidData: { - key: ONYXKEYS.PLAID_DATA, - }, - }), -)(BankAccountPlaidStep); diff --git a/src/pages/ReimbursementAccount/BankAccountStep.js b/src/pages/ReimbursementAccount/BankAccountStep.js index 408c0e46a47d..b8569436e5d0 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.js +++ b/src/pages/ReimbursementAccount/BankAccountStep.js @@ -22,13 +22,13 @@ import getPlaidDesktopMessage from '@libs/getPlaidDesktopMessage'; import variables from '@styles/variables'; import * as BankAccounts from '@userActions/BankAccounts'; import * as Link from '@userActions/Link'; +import * as ReimbursementAccount from '@userActions/ReimbursementAccount'; import * as Session from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import BankAccountManualStep from './BankAccountManualStep'; -import BankAccountPlaidStep from './BankAccountPlaidStep'; +import BankInfo from './BankInfo/BankInfo'; import StepPropTypes from './StepPropTypes'; const propTypes = { @@ -65,6 +65,8 @@ const defaultProps = { policyID: '', }; +const bankInfoStepKeys = CONST.BANK_ACCOUNT.BANK_INFO_STEP.INPUT_KEY; + function BankAccountStep(props) { const theme = useTheme(); const styles = useThemeStyles(); @@ -80,26 +82,21 @@ function BankAccountStep(props) { ROUTES.WORKSPACE_INITIAL.getRoute(props.policyID), )}`; - if (subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { - return ( - - ); - } + const removeExistingBankAccountDetails = () => { + const bankAccountData = { + [bankInfoStepKeys.ROUTING_NUMBER]: '', + [bankInfoStepKeys.ACCOUNT_NUMBER]: '', + [bankInfoStepKeys.PLAID_MASK]: '', + [bankInfoStepKeys.IS_SAVINGS]: '', + [bankInfoStepKeys.BANK_NAME]: '', + [bankInfoStepKeys.PLAID_ACCOUNT_ID]: '', + [bankInfoStepKeys.PLAID_ACCESS_TOKEN]: '', + }; + ReimbursementAccount.updateReimbursementAccountDraft(bankAccountData); + }; - if (subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID) { - return ( - - ); + if (subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID || subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { + return ; } return ( @@ -136,6 +133,7 @@ function BankAccountStep(props) { if (props.isPlaidDisabled || !props.user.validated) { return; } + removeExistingBankAccountDetails(); BankAccounts.openPlaidView(); }} isDisabled={props.isPlaidDisabled || !props.user.validated} @@ -151,7 +149,10 @@ function BankAccountStep(props) { icon={Expensicons.Connect} title={props.translate('bankAccount.connectManually')} disabled={!props.user.validated} - onPress={() => BankAccounts.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL)} + onPress={() => { + removeExistingBankAccountDetails(); + BankAccounts.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL); + }} shouldShowRightIcon wrapperStyle={[styles.cardMenuItem]} /> diff --git a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx new file mode 100644 index 000000000000..94b196176b7e --- /dev/null +++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx @@ -0,0 +1,162 @@ +import React, {useCallback, useEffect, useMemo} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useSubStep from '@hooks/useSubStep'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import useThemeStyles from '@hooks/useThemeStyles'; +import getPlaidOAuthReceivedRedirectURI from '@libs/getPlaidOAuthReceivedRedirectURI'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import * as BankAccounts from '@userActions/BankAccounts'; +import * as ReimbursementAccountUtils from '@userActions/ReimbursementAccount'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReimbursementAccount, ReimbursementAccountForm} from '@src/types/onyx'; +import Confirmation from './substeps/Confirmation'; +import Manual from './substeps/Manual'; +import Plaid from './substeps/Plaid'; + +type BankInfoOnyxProps = { + /** Plaid SDK token to use to initialize the widget */ + plaidLinkToken: OnyxEntry; + + /** Reimbursement account from ONYX */ + reimbursementAccount: OnyxEntry; + + /** The draft values of the bank account being setup */ + reimbursementAccountDraft: OnyxEntry; +}; + +type BankInfoProps = BankInfoOnyxProps & { + /** Goes to the previous step */ + onBackButtonPress: () => void; +}; + +const BANK_INFO_STEP_KEYS = CONST.BANK_ACCOUNT.BANK_INFO_STEP.INPUT_KEY; +const manualSubsteps: Array> = [Manual, Confirmation]; +const plaidSubsteps: Array> = [Plaid, Confirmation]; +const receivedRedirectURI = getPlaidOAuthReceivedRedirectURI(); + +function BankInfo({reimbursementAccount, reimbursementAccountDraft, plaidLinkToken, onBackButtonPress}: BankInfoProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const [redirectedFromPlaidToManual, setRedirectedFromPlaidToManual] = React.useState(false); + const values = useMemo(() => getSubstepValues(BANK_INFO_STEP_KEYS, reimbursementAccountDraft ?? {}, reimbursementAccount ?? {}), [reimbursementAccount, reimbursementAccountDraft]); + + let setupType = reimbursementAccount?.achData?.subStep ?? ''; + + const shouldReinitializePlaidLink = plaidLinkToken && receivedRedirectURI && setupType !== CONST.BANK_ACCOUNT.SUBSTEP.MANUAL; + if (shouldReinitializePlaidLink) { + setupType = CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID; + } + + const policyID = reimbursementAccount?.achData?.policyID ?? ''; + const bankAccountID = Number(reimbursementAccount?.achData?.bankAccountID ?? '0'); + const submit = useCallback(() => { + if (setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { + BankAccounts.connectBankAccountManually( + bankAccountID, + values[BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER], + values[BANK_INFO_STEP_KEYS.ROUTING_NUMBER], + values[BANK_INFO_STEP_KEYS.PLAID_MASK], + policyID, + ); + } else if (setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID) { + BankAccounts.connectBankAccountWithPlaid( + bankAccountID, + { + [BANK_INFO_STEP_KEYS.ROUTING_NUMBER]: values[BANK_INFO_STEP_KEYS.ROUTING_NUMBER] ?? '', + [BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER]: values[BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER] ?? '', + [BANK_INFO_STEP_KEYS.BANK_NAME]: values[BANK_INFO_STEP_KEYS.BANK_NAME] ?? '', + [BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID]: values[BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID] ?? '', + [BANK_INFO_STEP_KEYS.PLAID_ACCESS_TOKEN]: values[BANK_INFO_STEP_KEYS.PLAID_ACCESS_TOKEN] ?? '', + }, + policyID, + ); + } + }, [setupType, values, bankAccountID, policyID]); + + const bodyContent = setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID ? plaidSubsteps : manualSubsteps; + const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo} = useSubStep({bodyContent, startFrom: 0, onFinished: submit}); + + // Some services user connects to via Plaid return dummy account numbers and routing numbers e.g. Chase + // In this case we need to redirect user to manual flow to enter real account number and routing number + // and we need to do it only once so redirectedFromPlaidToManual flag is used + useEffect(() => { + if (redirectedFromPlaidToManual) { + return; + } + + if (setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL && values.bankName !== '' && !redirectedFromPlaidToManual) { + setRedirectedFromPlaidToManual(true); + moveTo(0); + } + }, [moveTo, redirectedFromPlaidToManual, setupType, values]); + + const handleBackButtonPress = () => { + if (screenIndex === 0) { + if (bankAccountID) { + onBackButtonPress(); + } else { + const bankAccountData = { + [BANK_INFO_STEP_KEYS.ROUTING_NUMBER]: '', + [BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER]: '', + [BANK_INFO_STEP_KEYS.PLAID_MASK]: '', + [BANK_INFO_STEP_KEYS.IS_SAVINGS]: '', + [BANK_INFO_STEP_KEYS.BANK_NAME]: '', + [BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID]: '', + [BANK_INFO_STEP_KEYS.PLAID_ACCESS_TOKEN]: '', + }; + ReimbursementAccountUtils.updateReimbursementAccountDraft(bankAccountData); + BankAccounts.setBankAccountSubStep(null); + } + } else { + prevScreen(); + } + }; + + return ( + + + + + + + + ); +} + +BankInfo.displayName = 'BankInfo'; + +export default withOnyx({ + reimbursementAccount: { + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + }, + reimbursementAccountDraft: { + key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, + }, + plaidLinkToken: { + key: ONYXKEYS.PLAID_LINK_TOKEN, + }, +})(BankInfo); diff --git a/src/pages/ReimbursementAccount/BankInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/BankInfo/substeps/Confirmation.tsx new file mode 100644 index 000000000000..c1fc3f82b277 --- /dev/null +++ b/src/pages/ReimbursementAccount/BankInfo/substeps/Confirmation.tsx @@ -0,0 +1,110 @@ +import React, {useMemo} from 'react'; +import {ScrollView, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReimbursementAccount, ReimbursementAccountForm} from '@src/types/onyx'; + +type ConfirmationOnyxProps = { + /** Reimbursement account from ONYX */ + reimbursementAccount: OnyxEntry; + + /** The draft values of the bank account being setup */ + reimbursementAccountDraft: OnyxEntry; +}; + +type ConfirmationProps = ConfirmationOnyxProps & SubStepProps; + +const BANK_INFO_STEP_KEYS = CONST.BANK_ACCOUNT.BANK_INFO_STEP.INPUT_KEY; +const BANK_INFO_STEP_INDEXES = CONST.REIMBURSEMENT_ACCOUNT_SUBSTEP_INDEX.BANK_ACCOUNT; + +function Confirmation({reimbursementAccount, reimbursementAccountDraft, onNext, onMove}: ConfirmationProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const isLoading = reimbursementAccount?.isLoading ?? false; + const setupType = reimbursementAccount?.achData?.subStep ?? ''; + const bankAccountID = Number(reimbursementAccount?.achData?.bankAccountID ?? '0'); + const values = useMemo(() => getSubstepValues(BANK_INFO_STEP_KEYS, reimbursementAccountDraft ?? {}, reimbursementAccount ?? {}), [reimbursementAccount, reimbursementAccountDraft]); + const error = ErrorUtils.getLatestErrorMessage(reimbursementAccount ?? {}); + + const handleModifyAccountNumbers = () => { + onMove(BANK_INFO_STEP_INDEXES.ACCOUNT_NUMBERS); + }; + + return ( + + + {translate('bankAccount.letsDoubleCheck')} + {translate('bankAccount.thisBankAccount')} + {setupType === CONST.BANK_ACCOUNT.SUBSTEP.MANUAL && ( + + + + + + )} + {setupType === CONST.BANK_ACCOUNT.SUBSTEP.PLAID && ( + + )} + + {error && error.length > 0 && ( + + )} +