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__/fs/promises.js b/__mocks__/fs/promises.js deleted file mode 100644 index 1a58f0f013ac..000000000000 --- a/__mocks__/fs/promises.js +++ /dev/null @@ -1,3 +0,0 @@ -const {fs} = require('memfs'); - -module.exports = fs.promises; diff --git a/__mocks__/fs/promises.ts b/__mocks__/fs/promises.ts new file mode 100644 index 000000000000..b831fffcefb8 --- /dev/null +++ b/__mocks__/fs/promises.ts @@ -0,0 +1,8 @@ +import {fs} from 'memfs'; +import type {FsPromisesApi} from 'memfs/lib/node/types'; + +type PromisesMock = FsPromisesApi; + +const promisesMock: PromisesMock = fs.promises; + +export default promisesMock; 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-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 7bfb88bde255..72cf2bbc9b6d 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 1001044000 - versionName "1.4.40-0" + versionCode 1001044102 + versionName "1.4.41-2" } 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/emojis/common.ts b/assets/emojis/common.ts index b23383590c51..c19d958812d1 100644 --- a/assets/emojis/common.ts +++ b/assets/emojis/common.ts @@ -2174,62 +2174,62 @@ const emojis: PickerEmojis = [ { name: 'people_holding_hands', code: '🧑‍🤝‍🧑', - types: ['🧑🏿‍🤝‍🧑🏿', '🧑🏿‍🤝‍🧑🏾', '🧑🏿‍🤝‍🧑🏽', '🧑🏿‍🤝‍🧑🏼', '🧑🏿‍🤝‍🧑🏻', '🧑🏾‍🤝‍🧑🏿', '🧑🏾‍🤝‍🧑🏾', '🧑🏾‍🤝‍🧑🏽', '🧑🏾‍🤝‍🧑🏼', '🧑🏾‍🤝‍🧑🏻', '🧑🏽‍🤝‍🧑🏿', '🧑🏽‍🤝‍🧑🏾', '🧑🏽‍🤝‍🧑🏽', '🧑🏽‍🤝‍🧑🏼', '🧑🏽‍🤝‍🧑🏻', '🧑🏼‍🤝‍🧑🏿', '🧑🏼‍🤝‍🧑🏾', '🧑🏼‍🤝‍🧑🏽', '🧑🏼‍🤝‍🧑🏼', '🧑🏼‍🤝‍🧑🏻', '🧑🏻‍🤝‍🧑🏿', '🧑🏻‍🤝‍🧑🏾', '🧑🏻‍🤝‍🧑🏽', '🧑🏻‍🤝‍🧑🏼', '🧑🏻‍🤝‍🧑🏻'], + types: ['🧑🏿‍🤝‍🧑🏿', '🧑🏾‍🤝‍🧑🏾', '🧑🏽‍🤝‍🧑🏽', '🧑🏼‍🤝‍🧑🏼', '🧑🏻‍🤝‍🧑🏻'], }, { name: 'two_women_holding_hands', code: '👭', - types: ['👩🏿‍🤝‍👩🏾', '👩🏿‍🤝‍👩🏽', '👩🏿‍🤝‍👩🏼', '👩🏿‍🤝‍👩🏻', '👩🏾‍🤝‍👩🏿', '👩🏾‍🤝‍👩🏽', '👩🏾‍🤝‍👩🏼', '👩🏾‍🤝‍👩🏻', '👩🏽‍🤝‍👩🏿', '👩🏽‍🤝‍👩🏾', '👩🏽‍🤝‍👩🏼', '👩🏽‍🤝‍👩🏻', '👩🏼‍🤝‍👩🏿', '👩🏼‍🤝‍👩🏾', '👩🏼‍🤝‍👩🏽', '👩🏼‍🤝‍👩🏻', '👩🏻‍🤝‍👩🏿', '👩🏻‍🤝‍👩🏾', '👩🏻‍🤝‍👩🏽', '👩🏻‍🤝‍👩🏼', '👭🏿', '👭🏾', '👭🏽', '👭🏼', '👭🏻'], + types: ['👭🏿', '👭🏾', '👭🏽', '👭🏼', '👭🏻'], }, { name: 'couple', code: '👫', - types: ['👩🏿‍🤝‍👨🏾', '👩🏿‍🤝‍👨🏽', '👩🏿‍🤝‍👨🏼', '👩🏿‍🤝‍👨🏻', '👩🏾‍🤝‍👨🏿', '👩🏾‍🤝‍👨🏽', '👩🏾‍🤝‍👨🏼', '👩🏾‍🤝‍👨🏻', '👩🏽‍🤝‍👨🏿', '👩🏽‍🤝‍👨🏾', '👩🏽‍🤝‍👨🏼', '👩🏽‍🤝‍👨🏻', '👩🏼‍🤝‍👨🏿', '👩🏼‍🤝‍👨🏾', '👩🏼‍🤝‍👨🏽', '👩🏼‍🤝‍👨🏻', '👩🏻‍🤝‍👨🏿', '👩🏻‍🤝‍👨🏾', '👩🏻‍🤝‍👨🏽', '👩🏻‍🤝‍👨🏼', '👫🏿', '👫🏾', '👫🏽', '👫🏼', '👫🏻'], + types: ['👫🏿', '👫🏾', '👫🏽', '👫🏼', '👫🏻'], }, { name: 'two_men_holding_hands', code: '👬', - types: ['👨🏿‍🤝‍👨🏾', '👨🏿‍🤝‍👨🏽', '👨🏿‍🤝‍👨🏼', '👨🏿‍🤝‍👨🏻', '👨🏾‍🤝‍👨🏿', '👨🏾‍🤝‍👨🏽', '👨🏾‍🤝‍👨🏼', '👨🏾‍🤝‍👨🏻', '👨🏽‍🤝‍👨🏿', '👨🏽‍🤝‍👨🏾', '👨🏽‍🤝‍👨🏼', '👨🏽‍🤝‍👨🏻', '👨🏼‍🤝‍👨🏿', '👨🏼‍🤝‍👨🏾', '👨🏼‍🤝‍👨🏽', '👨🏼‍🤝‍👨🏻', '👨🏻‍🤝‍👨🏿', '👨🏻‍🤝‍👨🏾', '👨🏻‍🤝‍👨🏽', '👨🏻‍🤝‍👨🏼', '👬🏿', '👬🏾', '👬🏽', '👬🏼', '👬🏻'], + types: ['👬🏿', '👬🏾', '👬🏽', '👬🏼', '👬🏻'], }, { name: 'couplekiss', code: '💏', - types: ['🧑🏿‍❤️‍💋‍🧑🏾', '🧑🏿‍❤️‍💋‍🧑🏽', '🧑🏿‍❤️‍💋‍🧑🏼', '🧑🏿‍❤️‍💋‍🧑🏻', '🧑🏾‍❤️‍💋‍🧑🏿', '🧑🏾‍❤️‍💋‍🧑🏽', '🧑🏾‍❤️‍💋‍🧑🏼', '🧑🏾‍❤️‍💋‍🧑🏻', '🧑🏽‍❤️‍💋‍🧑🏿', '🧑🏽‍❤️‍💋‍🧑🏾', '🧑🏽‍❤️‍💋‍🧑🏼', '🧑🏽‍❤️‍💋‍🧑🏻', '🧑🏼‍❤️‍💋‍🧑🏿', '🧑🏼‍❤️‍💋‍🧑🏾', '🧑🏼‍❤️‍💋‍🧑🏽', '🧑🏼‍❤️‍💋‍🧑🏻', '🧑🏻‍❤️‍💋‍🧑🏿', '🧑🏻‍❤️‍💋‍🧑🏾', '🧑🏻‍❤️‍💋‍🧑🏽', '🧑🏻‍❤️‍💋‍🧑🏼', '💏🏿', '💏🏾', '💏🏽', '💏🏼', '💏🏻'], + types: ['💏🏿', '💏🏾', '💏🏽', '💏🏼', '💏🏻'], }, { name: 'couplekiss_man_woman', code: '👩‍❤️‍💋‍👨', - types: ['👩🏿‍❤️‍💋‍👨🏿', '👩🏿‍❤️‍💋‍👨🏾', '👩🏿‍❤️‍💋‍👨🏽', '👩🏿‍❤️‍💋‍👨🏼', '👩🏿‍❤️‍💋‍👨🏻', '👩🏾‍❤️‍💋‍👨🏿', '👩🏾‍❤️‍💋‍👨🏾', '👩🏾‍❤️‍💋‍👨🏽', '👩🏾‍❤️‍💋‍👨🏼', '👩🏾‍❤️‍💋‍👨🏻', '👩🏽‍❤️‍💋‍👨🏿', '👩🏽‍❤️‍💋‍👨🏾', '👩🏽‍❤️‍💋‍👨🏽', '👩🏽‍❤️‍💋‍👨🏼', '👩🏽‍❤️‍💋‍👨🏻', '👩🏼‍❤️‍💋‍👨🏿', '👩🏼‍❤️‍💋‍👨🏾', '👩🏼‍❤️‍💋‍👨🏽', '👩🏼‍❤️‍💋‍👨🏼', '👩🏼‍❤️‍💋‍👨🏻', '👩🏻‍❤️‍💋‍👨🏿', '👩🏻‍❤️‍💋‍👨🏾', '👩🏻‍❤️‍💋‍👨🏽', '👩🏻‍❤️‍💋‍👨🏼', '👩🏻‍❤️‍💋‍👨🏻'], + types: ['👩🏿‍❤️‍💋‍👨🏿', '👩🏾‍❤️‍💋‍👨🏾', '👩🏽‍❤️‍💋‍👨🏽', '👩🏼‍❤️‍💋‍👨🏼', '👩🏻‍❤️‍💋‍👨🏻'], }, { name: 'couplekiss_man_man', code: '👨‍❤️‍💋‍👨', - types: ['👨🏿‍❤️‍💋‍👨🏿', '👨🏿‍❤️‍💋‍👨🏾', '👨🏿‍❤️‍💋‍👨🏽', '👨🏿‍❤️‍💋‍👨🏼', '👨🏿‍❤️‍💋‍👨🏻', '👨🏾‍❤️‍💋‍👨🏿', '👨🏾‍❤️‍💋‍👨🏾', '👨🏾‍❤️‍💋‍👨🏽', '👨🏾‍❤️‍💋‍👨🏼', '👨🏾‍❤️‍💋‍👨🏻', '👨🏽‍❤️‍💋‍👨🏿', '👨🏽‍❤️‍💋‍👨🏾', '👨🏽‍❤️‍💋‍👨🏽', '👨🏽‍❤️‍💋‍👨🏼', '👨🏽‍❤️‍💋‍👨🏻', '👨🏼‍❤️‍💋‍👨🏿', '👨🏼‍❤️‍💋‍👨🏾', '👨🏼‍❤️‍💋‍👨🏽', '👨🏼‍❤️‍💋‍👨🏼', '👨🏼‍❤️‍💋‍👨🏻', '👨🏻‍❤️‍💋‍👨🏿', '👨🏻‍❤️‍💋‍👨🏾', '👨🏻‍❤️‍💋‍👨🏽', '👨🏻‍❤️‍💋‍👨🏼', '👨🏻‍❤️‍💋‍👨🏻'], + types: ['👨🏿‍❤️‍💋‍👨🏿', '👨🏾‍❤️‍💋‍👨🏾', '👨🏽‍❤️‍💋‍👨🏽', '👨🏼‍❤️‍💋‍👨🏼', '👨🏻‍❤️‍💋‍👨🏻'], }, { name: 'couplekiss_woman_woman', code: '👩‍❤️‍💋‍👩', - types: ['👩🏿‍❤️‍💋‍👩🏿', '👩🏿‍❤️‍💋‍👩🏾', '👩🏿‍❤️‍💋‍👩🏽', '👩🏿‍❤️‍💋‍👩🏼', '👩🏿‍❤️‍💋‍👩🏻', '👩🏾‍❤️‍💋‍👩🏿', '👩🏾‍❤️‍💋‍👩🏾', '👩🏾‍❤️‍💋‍👩🏽', '👩🏾‍❤️‍💋‍👩🏼', '👩🏾‍❤️‍💋‍👩🏻', '👩🏽‍❤️‍💋‍👩🏿', '👩🏽‍❤️‍💋‍👩🏾', '👩🏽‍❤️‍💋‍👩🏽', '👩🏽‍❤️‍💋‍👩🏼', '👩🏽‍❤️‍💋‍👩🏻', '👩🏼‍❤️‍💋‍👩🏿', '👩🏼‍❤️‍💋‍👩🏾', '👩🏼‍❤️‍💋‍👩🏽', '👩🏼‍❤️‍💋‍👩🏼', '👩🏼‍❤️‍💋‍👩🏻', '👩🏻‍❤️‍💋‍👩🏿', '👩🏻‍❤️‍💋‍👩🏾', '👩🏻‍❤️‍💋‍👩🏽', '👩🏻‍❤️‍💋‍👩🏼', '👩🏻‍❤️‍💋‍👩🏻'], + types: ['👩🏿‍❤️‍💋‍👩🏿', '👩🏾‍❤️‍💋‍👩🏾', '👩🏽‍❤️‍💋‍👩🏽', '👩🏼‍❤️‍💋‍👩🏼', '👩🏻‍❤️‍💋‍👩🏻'], }, { name: 'couple_with_heart', code: '💑', - types: ['🧑🏿‍❤️‍🧑🏾', '🧑🏿‍❤️‍🧑🏽', '🧑🏿‍❤️‍🧑🏼', '🧑🏿‍❤️‍🧑🏻', '🧑🏾‍❤️‍🧑🏿', '🧑🏾‍❤️‍🧑🏽', '🧑🏾‍❤️‍🧑🏼', '🧑🏾‍❤️‍🧑🏻', '🧑🏽‍❤️‍🧑🏿', '🧑🏽‍❤️‍🧑🏾', '🧑🏽‍❤️‍🧑🏼', '🧑🏽‍❤️‍🧑🏻', '🧑🏼‍❤️‍🧑🏿', '🧑🏼‍❤️‍🧑🏾', '🧑🏼‍❤️‍🧑🏽', '🧑🏼‍❤️‍🧑🏻', '🧑🏻‍❤️‍🧑🏿', '🧑🏻‍❤️‍🧑🏾', '🧑🏻‍❤️‍🧑🏽', '🧑🏻‍❤️‍🧑🏼', '💑🏿', '💑🏾', '💑🏽', '💑🏼', '💑🏻'], + types: ['💑🏿', '💑🏾', '💑🏽', '💑🏼', '💑🏻'], }, { name: 'couple_with_heart_woman_man', code: '👩‍❤️‍👨', - types: ['👩🏿‍❤️‍👨🏿', '👩🏿‍❤️‍👨🏾', '👩🏿‍❤️‍👨🏽', '👩🏿‍❤️‍👨🏼', '👩🏿‍❤️‍👨🏻', '👩🏾‍❤️‍👨🏿', '👩🏾‍❤️‍👨🏾', '👩🏾‍❤️‍👨🏽', '👩🏾‍❤️‍👨🏼', '👩🏾‍❤️‍👨🏻', '👩🏽‍❤️‍👨🏿', '👩🏽‍❤️‍👨🏾', '👩🏽‍❤️‍👨🏽', '👩🏽‍❤️‍👨🏼', '👩🏽‍❤️‍👨🏻', '👩🏼‍❤️‍👨🏿', '👩🏼‍❤️‍👨🏾', '👩🏼‍❤️‍👨🏽', '👩🏼‍❤️‍👨🏼', '👩🏼‍❤️‍👨🏻', '👩🏻‍❤️‍👨🏿', '👩🏻‍❤️‍👨🏾', '👩🏻‍❤️‍👨🏽', '👩🏻‍❤️‍👨🏼', '👩🏻‍❤️‍👨🏻'], + types: ['👩🏿‍❤️‍👨🏿', '👩🏾‍❤️‍👨🏾', '👩🏽‍❤️‍👨🏽', '👩🏼‍❤️‍👨🏼', '👩🏻‍❤️‍👨🏻'], }, { name: 'couple_with_heart_man_man', code: '👨‍❤️‍👨', - types: ['👨🏿‍❤️‍👨🏿', '👨🏿‍❤️‍👨🏾', '👨🏿‍❤️‍👨🏽', '👨🏿‍❤️‍👨🏼', '👨🏿‍❤️‍👨🏻', '👨🏾‍❤️‍👨🏿', '👨🏾‍❤️‍👨🏾', '👨🏾‍❤️‍👨🏽', '👨🏾‍❤️‍👨🏼', '👨🏾‍❤️‍👨🏻', '👨🏽‍❤️‍👨🏿', '👨🏽‍❤️‍👨🏾', '👨🏽‍❤️‍👨🏽', '👨🏽‍❤️‍👨🏼', '👨🏽‍❤️‍👨🏻', '👨🏼‍❤️‍👨🏿', '👨🏼‍❤️‍👨🏾', '👨🏼‍❤️‍👨🏽', '👨🏼‍❤️‍👨🏼', '👨🏼‍❤️‍👨🏻', '👨🏻‍❤️‍👨🏿', '👨🏻‍❤️‍👨🏾', '👨🏻‍❤️‍👨🏽', '👨🏻‍❤️‍👨🏼', '👨🏻‍❤️‍👨🏻'], + types: ['👨🏿‍❤️‍👨🏿', '👨🏾‍❤️‍👨🏾', '👨🏽‍❤️‍👨🏽', '👨🏼‍❤️‍👨🏼', '👨🏻‍❤️‍👨🏻'], }, { name: 'couple_with_heart_woman_woman', code: '👩‍❤️‍👩', - types: ['👩🏿‍❤️‍👩🏿', '👩🏿‍❤️‍👩🏾', '👩🏿‍❤️‍👩🏽', '👩🏿‍❤️‍👩🏼', '👩🏿‍❤️‍👩🏻', '👩🏾‍❤️‍👩🏿', '👩🏾‍❤️‍👩🏾', '👩🏾‍❤️‍👩🏽', '👩🏾‍❤️‍👩🏼', '👩🏾‍❤️‍👩🏻', '👩🏽‍❤️‍👩🏿', '👩🏽‍❤️‍👩🏾', '👩🏽‍❤️‍👩🏽', '👩🏽‍❤️‍👩🏼', '👩🏽‍❤️‍👩🏻', '👩🏼‍❤️‍👩🏿', '👩🏼‍❤️‍👩🏾', '👩🏼‍❤️‍👩🏽', '👩🏼‍❤️‍👩🏼', '👩🏼‍❤️‍👩🏻', '👩🏻‍❤️‍👩🏿', '👩🏻‍❤️‍👩🏾', '👩🏻‍❤️‍👩🏽', '👩🏻‍❤️‍👩🏼', '👩🏻‍❤️‍👩🏻'], + types: ['👩🏿‍❤️‍👩🏿', '👩🏾‍❤️‍👩🏾', '👩🏽‍❤️‍👩🏽', '👩🏼‍❤️‍👩🏼', '👩🏻‍❤️‍👩🏻'], }, { name: 'family', 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..cc859f220608 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(), @@ -95,8 +97,10 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ {from: 'web/apple-touch-icon.png'}, {from: 'assets/images/expensify-app-icon.svg'}, {from: 'web/manifest.json'}, + {from: 'web/gtm.js'}, {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 +204,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 +243,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ 'process/browser': require.resolve('process/browser'), }, }, + optimization: { runtimeChunk: 'single', splitChunks: { diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index d87f9f889090..b2f912277dc5 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -10,7 +10,7 @@ Any form input needs to be wrapped in [InputWrapper](https://github.com/Expensif @@ -248,7 +248,7 @@ function onSubmit(values) { @@ -263,7 +263,7 @@ const BankAccountForm = () => ( @@ -271,7 +271,7 @@ const BankAccountForm = () => ( 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 094fa0ab6025..ffc0769a9571 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.40 + 1.4.41 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.40.0 + 1.4.41.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index b35e4393d66d..86825c7cc4a3 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.40 + 1.4.41 CFBundleSignature ???? CFBundleVersion - 1.4.40.0 + 1.4.41.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 72e43a33d4f7..06238fb247c9 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.40 + 1.4.41 CFBundleVersion - 1.4.40.0 + 1.4.41.2 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/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 f3a35a2b5d87..5db1d6cfe86d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.40-0", + "version": "1.4.41-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.40-0", + "version": "1.4.41-2", "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,14 +101,15 @@ "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", "react-native-reanimated": "^3.6.1", "react-native-render-html": "6.3.1", - "react-native-safe-area-context": "4.7.4", + "react-native-safe-area-context": "4.8.2", "react-native-screens": "3.29.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", @@ -116,6 +117,7 @@ "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" @@ -45322,9 +45329,9 @@ "license": "MIT" }, "node_modules/react-native-safe-area-context": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.7.4.tgz", - "integrity": "sha512-3LR3DCq9pdzlbq6vsHGWBFehXAKDh2Ljug6jWhLWs1QFuJHM6AS2+mH2JfKlB2LqiSFZOBcZfHQFz0sGaA3uqg==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.8.2.tgz", + "integrity": "sha512-ffUOv8BJQ6RqO3nLml5gxJ6ab3EestPiyWekxdzO/1MQ7NF8fW1Mzh1C5QE9yq573Xefnc7FuzGXjtesZGv7cQ==", "peerDependencies": { "react": "*", "react-native": "*" @@ -45343,6 +45350,14 @@ "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.1.0", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-14.1.0.tgz", @@ -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 72526d98bbdb..be8e65d59b70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.40-0", + "version": "1.4.41-2", "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,14 +149,15 @@ "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", "react-native-reanimated": "^3.6.1", "react-native-render-html": "6.3.1", - "react-native-safe-area-context": "4.7.4", + "react-native-safe-area-context": "4.8.2", "react-native-screens": "3.29.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", @@ -164,6 +165,7 @@ "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-config+1.4.6.patch b/patches/react-native-config+1.4.6.patch new file mode 100644 index 000000000000..880c723ddc37 --- /dev/null +++ b/patches/react-native-config+1.4.6.patch @@ -0,0 +1,44 @@ +diff --git a/node_modules/react-native-config/android/dotenv.gradle b/node_modules/react-native-config/android/dotenv.gradle +index 2225375..48f94ca 100644 +--- a/node_modules/react-native-config/android/dotenv.gradle ++++ b/node_modules/react-native-config/android/dotenv.gradle +@@ -41,7 +41,8 @@ def loadDotEnv(flavor = getCurrentFlavor()) { + def env = [:] + println("Reading env from: $envFile") + +- File f = new File("$project.rootDir/../$envFile"); ++ def reactNativeProjectRoot = project.hasProperty('reactNativeProject') ? project.reactNativeProject : ".." ++ File f = new File("$project.rootDir/$reactNativeProjectRoot/$envFile"); + if (!f.exists()) { + f = new File("$envFile"); + } +diff --git a/node_modules/react-native-config/react-native-config.podspec b/node_modules/react-native-config/react-native-config.podspec +index 54985dd..c394ec7 100644 +--- a/node_modules/react-native-config/react-native-config.podspec ++++ b/node_modules/react-native-config/react-native-config.podspec +@@ -3,6 +3,7 @@ + require 'json' + + package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) ++REACT_NATIVE_DIR = ENV["REACT_NATIVE_DIR"] || ".." + + Pod::Spec.new do |s| + s.name = 'react-native-config' +@@ -21,7 +22,7 @@ Pod::Spec.new do |s| + name: 'Config codegen', + script: %( + set -ex +-HOST_PATH="$SRCROOT/../.." ++HOST_PATH="$SRCROOT/../#{REACT_NATIVE_DIR}" + "${PODS_TARGET_SRCROOT}/ios/ReactNativeConfig/BuildDotenvConfig.rb" "$HOST_PATH" "${PODS_TARGET_SRCROOT}/ios/ReactNativeConfig" + ), + execution_position: :before_compile, +@@ -43,7 +44,7 @@ HOST_PATH="$SRCROOT/../.." + name: 'Config codegen', + script: %( + set -ex +- HOST_PATH="$SRCROOT/../.." ++ HOST_PATH="$SRCROOT/../#{REACT_NATIVE_DIR}" + "${PODS_TARGET_SRCROOT}/ios/ReactNativeConfig/BuildDotenvConfig.rb" "$HOST_PATH" "${PODS_TARGET_SRCROOT}/ios/ReactNativeConfig" + ), + execution_position: :before_compile, 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/src/App.tsx b/src/App.tsx index 7c1ead1d86d3..52baedc79421 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import Onyx from 'react-native-onyx'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; +import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; import ActiveWorkspaceContextProvider from './components/ActiveWorkspace/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; import ComposeProviders from './components/ComposeProviders'; @@ -78,6 +79,7 @@ function App({url}: AppProps) { PickerStateProvider, EnvironmentProvider, CustomStatusBarAndBackgroundContextProvider, + ActiveElementRoleProvider, ActiveWorkspaceContextProvider, ]} > diff --git a/src/CONST.ts b/src/CONST.ts index eae4b8ec7a2b..e33fc3eb23fb 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -195,6 +195,27 @@ const CONST = { DOMAIN: '@expensify.sms', }, BANK_ACCOUNT: { + 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, + }, + 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: { @@ -214,14 +235,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 +518,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: { @@ -1335,9 +1362,9 @@ const CONST = { OWNER_EMAIL_FAKE: '_FAKE_', OWNER_ACCOUNT_ID_FAKE: 0, REIMBURSEMENT_CHOICES: { - REIMBURSEMENT_YES: 'reimburseYes', // Direct - REIMBURSEMENT_NO: 'reimburseNo', // None - REIMBURSEMENT_MANUAL: 'reimburseManual', // Indirect + REIMBURSEMENT_YES: 'reimburseYes', + REIMBURSEMENT_NO: 'reimburseNo', + REIMBURSEMENT_MANUAL: 'reimburseManual', }, ID_FAKE: '_FAKE_', EMPTY: 'EMPTY', @@ -3194,6 +3221,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..0735bc53e56c 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,7 +1,9 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; +import type * as FormTypes from './types/form'; import type * as OnyxTypes from './types/onyx'; +import type AssertTypesEqual from './types/utils/AssertTypesEqual'; import type DeepValueOf from './types/utils/DeepValueOf'; /** @@ -63,6 +65,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 +143,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', @@ -228,9 +239,6 @@ const ONYXKEYS = { // The last update ID that was applied to the client ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT: 'OnyxUpdatesLastUpdateIDAppliedToClient', - // Receipt upload modal - RECEIPT_MODAL: 'receiptModal', - // The access token to be used with the Mapbox library MAPBOX_ACCESS_TOKEN: 'mapboxAccessToken', @@ -370,12 +378,85 @@ const ONYXKEYS = { }, } as const; -type OnyxKeysMap = typeof ONYXKEYS; -type OnyxCollectionKey = ValueOf; -type OnyxKey = DeepValueOf>; -type OnyxFormKey = ValueOf; +type AllOnyxKeys = DeepValueOf; + +type OnyxFormValuesMapping = { + [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: FormTypes.AddDebitCardForm; + [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: FormTypes.WorkspaceSettingsForm; + [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm; + [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; + [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; + [ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm; + [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: FormTypes.DateOfBirthForm; + [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.NEW_ROOM_FORM]: FormTypes.NewRoomForm; + [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.NEW_TASK_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.EDIT_TASK_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: FormTypes.PrivateNotesForm; + [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: FormTypes.IKnowTeacherForm; + [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM]: FormTypes.IntroSchoolPrincipalForm; + [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.Form; + [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: FormTypes.GetPhysicalCardForm; + [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: FormTypes.ReportFieldEditForm; + [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: FormTypes.ReimbursementAccountForm; + [ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT]: FormTypes.PersonalBankAccountForm; +}; + +type OnyxFormDraftValuesMapping = { + [K in keyof OnyxFormValuesMapping as `${K}Draft`]: OnyxFormValuesMapping[K]; +}; + +type OnyxCollectionValuesMapping = { + [ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download; + [ONYXKEYS.COLLECTION.POLICY]: OnyxTypes.Policy; + [ONYXKEYS.COLLECTION.POLICY_DRAFTS]: OnyxTypes.Policy; + [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategories; + [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTags; + [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMembers; + [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; + [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; + [ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS]: OnyxTypes.PolicyReportFields; + [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers; + [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs; + [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT]: string; + [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; + [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata; + [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportActions; + [ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS]: OnyxTypes.ReportActionsDrafts; + [ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS]: OnyxTypes.ReportActionReactions; + [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT]: string; + [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES]: number; + [ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE]: boolean; + [ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING]: OnyxTypes.ReportUserIsTyping; + [ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM]: boolean; + [ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup; + [ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction; + [ONYXKEYS.COLLECTION.TRANSACTION_DRAFT]: OnyxTypes.Transaction; + [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations; + [ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT]: OnyxTypes.Transaction; + [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; + [ONYXKEYS.COLLECTION.SELECTED_TAB]: string; + [ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string; + [ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStep; + [ONYXKEYS.COLLECTION.POLICY_TAX_RATE]: string[]; +}; -type OnyxValues = { +type OnyxValuesMapping = { [ONYXKEYS.ACCOUNT]: OnyxTypes.Account; [ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string; [ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER]: boolean; @@ -393,6 +474,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 +499,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,116 +533,27 @@ 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; + [ONYXKEYS.PLAID_CURRENT_EVENT]: string; +}; - // Collections - [ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download; - [ONYXKEYS.COLLECTION.POLICY]: OnyxTypes.Policy; - [ONYXKEYS.COLLECTION.POLICY_DRAFTS]: OnyxTypes.Policy; - [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategories; - [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTags; - [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMembers; - [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; - [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; - [ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS]: OnyxTypes.PolicyReportFields; - [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers; - [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs | undefined; - [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT]: string | undefined; - [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; - [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata; - [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportActions; - [ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS]: OnyxTypes.ReportActionsDrafts; - [ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS]: OnyxTypes.ReportActionReactions; - [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT]: string; - [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES]: number; - [ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE]: boolean; - [ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING]: OnyxTypes.ReportUserIsTyping; - [ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM]: boolean; - [ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup; - [ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction; - [ONYXKEYS.COLLECTION.TRANSACTION_DRAFT]: OnyxTypes.Transaction; - [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations; - [ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT]: OnyxTypes.Transaction; - [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; - [ONYXKEYS.COLLECTION.SELECTED_TAB]: string; - [ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string; - [ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStep; +type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; - // Forms - [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: OnyxTypes.AddDebitCardForm; - [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM_DRAFT]: OnyxTypes.AddDebitCardForm; - [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: OnyxTypes.WorkspaceSettingsForm; - [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM_DRAFT]: OnyxTypes.WorkspaceSettingsForm; - [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: OnyxTypes.CloseAccountForm; - [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM_DRAFT]: OnyxTypes.CloseAccountForm; - [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: OnyxTypes.DisplayNameForm; - [ONYXKEYS.FORMS.DISPLAY_NAME_FORM_DRAFT]: OnyxTypes.DisplayNameForm; - [ONYXKEYS.FORMS.ROOM_NAME_FORM]: OnyxTypes.RoomNameForm; - [ONYXKEYS.FORMS.ROOM_NAME_FORM_DRAFT]: OnyxTypes.RoomNameForm; - [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.LEGAL_NAME_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: OnyxTypes.DateOfBirthForm; - [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM_DRAFT]: OnyxTypes.DateOfBirthForm; - [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.HOME_ADDRESS_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.NEW_ROOM_FORM]: OnyxTypes.NewRoomForm; - [ONYXKEYS.FORMS.NEW_ROOM_FORM_DRAFT]: OnyxTypes.NewRoomForm; - [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.NEW_TASK_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.NEW_TASK_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.EDIT_TASK_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.EDIT_TASK_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.WAYPOINT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.WAYPOINT_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: OnyxTypes.PrivateNotesForm; - [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM_DRAFT]: OnyxTypes.PrivateNotesForm; - [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: OnyxTypes.IKnowATeacherForm; - [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM_DRAFT]: OnyxTypes.IKnowATeacherForm; - [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM]: OnyxTypes.IntroSchoolPrincipalForm; - [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM_DRAFT]: OnyxTypes.IntroSchoolPrincipalForm; - [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: OnyxTypes.Form; - [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: OnyxTypes.GetPhysicalCardForm; - [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.GetPhysicalCardForm; - [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.PERSONAL_BANK_ACCOUNT]: OnyxTypes.PersonalBankAccount; - [ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_DRAFT]: OnyxTypes.PersonalBankAccount; -}; +type OnyxCollectionKey = keyof OnyxCollectionValuesMapping; +type OnyxFormKey = keyof OnyxFormValuesMapping; +type OnyxFormDraftKey = keyof OnyxFormDraftValuesMapping; +type OnyxValueKey = keyof OnyxValuesMapping; + +type OnyxKey = OnyxValueKey | OnyxCollectionKey | OnyxFormKey | OnyxFormDraftKey; +type OnyxValue = OnyxEntry; -type OnyxKeyValue = OnyxEntry; +type MissingOnyxKeysError = `Error: Types don't match, OnyxKey type is missing: ${Exclude}`; +/** If this type errors, it means that the `OnyxKey` type is missing some keys. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type AssertOnyxKeys = AssertTypesEqual; export default ONYXKEYS; -export type {OnyxKey, OnyxCollectionKey, OnyxValues, OnyxKeyValue, OnyxFormKey, OnyxKeysMap}; +export type {OnyxValues, OnyxKey, OnyxCollectionKey, OnyxValue, OnyxValueKey, OnyxFormKey, OnyxFormValuesMapping, OnyxFormDraftKey}; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 3e0f1c5cb4dd..082cea49d771 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -464,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 7cc80fa837a1..1626fdbd1898 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -197,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/ActiveElementRoleProvider/index.native.tsx b/src/components/ActiveElementRoleProvider/index.native.tsx new file mode 100644 index 000000000000..4a9f2290b2b0 --- /dev/null +++ b/src/components/ActiveElementRoleProvider/index.native.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import type {ActiveElementRoleContextValue, ActiveElementRoleProps} from './types'; + +const ActiveElementRoleContext = React.createContext({ + role: null, +}); + +function ActiveElementRoleProvider({children}: ActiveElementRoleProps) { + const value = React.useMemo( + () => ({ + role: null, + }), + [], + ); + + return {children}; +} + +export default ActiveElementRoleProvider; +export {ActiveElementRoleContext}; diff --git a/src/components/ActiveElementRoleProvider/index.tsx b/src/components/ActiveElementRoleProvider/index.tsx new file mode 100644 index 000000000000..630af8618c08 --- /dev/null +++ b/src/components/ActiveElementRoleProvider/index.tsx @@ -0,0 +1,40 @@ +import React, {useEffect, useState} from 'react'; +import type {ActiveElementRoleContextValue, ActiveElementRoleProps} from './types'; + +const ActiveElementRoleContext = React.createContext({ + role: null, +}); + +function ActiveElementRoleProvider({children}: ActiveElementRoleProps) { + const [activeRoleRef, setRole] = useState(document?.activeElement?.role ?? null); + + const handleFocusIn = () => { + setRole(document?.activeElement?.role ?? null); + }; + + const handleFocusOut = () => { + setRole(null); + }; + + useEffect(() => { + document.addEventListener('focusin', handleFocusIn); + document.addEventListener('focusout', handleFocusOut); + + return () => { + document.removeEventListener('focusin', handleFocusIn); + document.removeEventListener('focusout', handleFocusOut); + }; + }, []); + + const value = React.useMemo( + () => ({ + role: activeRoleRef, + }), + [activeRoleRef], + ); + + return {children}; +} + +export default ActiveElementRoleProvider; +export {ActiveElementRoleContext}; diff --git a/src/components/ActiveElementRoleProvider/types.ts b/src/components/ActiveElementRoleProvider/types.ts new file mode 100644 index 000000000000..f22343b12550 --- /dev/null +++ b/src/components/ActiveElementRoleProvider/types.ts @@ -0,0 +1,9 @@ +type ActiveElementRoleContextValue = { + role: string | null; +}; + +type ActiveElementRoleProps = { + children: React.ReactNode; +}; + +export type {ActiveElementRoleContextValue, ActiveElementRoleProps}; 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/AddressForm.js b/src/components/AddressForm.js index aee1b652b22c..1bd55004074a 100644 --- a/src/components/AddressForm.js +++ b/src/components/AddressForm.js @@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; +import INPUT_IDS from '@src/types/form/HomeAddressForm'; import AddressSearch from './AddressSearch'; import CountrySelector from './CountrySelector'; import FormProvider from './Form/FormProvider'; @@ -127,7 +128,7 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS { onAddressChanged(data, key); @@ -136,12 +137,12 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS }} defaultValue={street1 || ''} renamedInputKeys={{ - street: 'addressLine1', - street2: 'addressLine2', - city: 'city', - state: 'state', - zipCode: 'zipPostCode', - country: 'country', + street: INPUT_IDS.ADDRESS_LINE_1, + street2: INPUT_IDS.ADDRESS_LINE_2, + city: INPUT_IDS.CITY, + state: INPUT_IDS.STATE, + zipCode: INPUT_IDS.ZIP_POST_CODE, + country: INPUT_IDS.COUNTRY, }} maxInputLength={CONST.FORM_CHARACTER_LIMIT} shouldSaveDraft={shouldSaveDraft} @@ -150,7 +151,7 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS @@ -173,7 +174,7 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS 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/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index f55db3dd0620..26d41ea82e00 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -100,6 +100,9 @@ const propTypes = { horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), }), + + /** Allows to open an image without Attachment Picker. */ + enablePreview: PropTypes.bool, }; const defaultProps = { @@ -127,6 +130,7 @@ const defaultProps = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, }, + enablePreview: false, }; function AvatarWithImagePicker({ @@ -152,6 +156,7 @@ function AvatarWithImagePicker({ avatarStyle, disabled, onViewPhotoPress, + enablePreview, }) { const theme = useTheme(); const styles = useThemeStyles(); @@ -330,10 +335,16 @@ function AvatarWithImagePicker({ text={translate('avatarWithImagePicker.editImage')} > setIsMenuVisible((prev) => !prev)} + onPress={() => { + if (disabled && enablePreview && onViewPhotoPress) { + onViewPhotoPress(); + return; + } + setIsMenuVisible((prev) => !prev); + }} accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={translate('avatarWithImagePicker.editImage')} - disabled={isAvatarCropModalOpen || disabled} + disabled={isAvatarCropModalOpen || (disabled && !enablePreview)} disabledStyle={disabledStyle} ref={anchorRef} > 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/Button/index.tsx b/src/components/Button/index.tsx index f4b6e8b23ecf..1961829b6aa7 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -265,14 +265,16 @@ function Button( return ( <> - + {pressOnEnter && ( + + )} { 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/DeeplinkWrapper/index.website.tsx b/src/components/DeeplinkWrapper/index.website.tsx index 1d509666ec98..765fbab03876 100644 --- a/src/components/DeeplinkWrapper/index.website.tsx +++ b/src/components/DeeplinkWrapper/index.website.tsx @@ -5,6 +5,7 @@ import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import shouldPreventDeeplinkPrompt from '@libs/Navigation/shouldPreventDeeplinkPrompt'; import * as App from '@userActions/App'; +import * as Session from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -62,7 +63,14 @@ function DeeplinkWrapper({children, isAuthenticated, autoAuthState}: DeeplinkWra const isUnsupportedDeeplinkRoute = routeRegex.test(window.location.pathname); // Making a few checks to exit early before checking authentication status - if (!isMacOSWeb() || isUnsupportedDeeplinkRoute || hasShownPrompt || CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.DEV || autoAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED) { + if ( + !isMacOSWeb() || + isUnsupportedDeeplinkRoute || + hasShownPrompt || + CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.DEV || + autoAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED || + Session.isAnonymousUser() + ) { return; } // We want to show the prompt immediately if the user is already authenticated. 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/FormContext.tsx b/src/components/Form/FormContext.tsx index 47e0de8b497c..8e3c30303e1f 100644 --- a/src/components/Form/FormContext.tsx +++ b/src/components/Form/FormContext.tsx @@ -1,6 +1,10 @@ import {createContext} from 'react'; -import type {RegisterInput} from './types'; +import type {Form} from '@src/types/form'; +import type {InputComponentBaseProps} from './types'; +type InputProps = Omit; + +type RegisterInput = (inputID: keyof Form, shouldSubmitForm: boolean, inputProps: InputProps) => InputProps; type FormContext = { registerInput: RegisterInput; }; @@ -10,3 +14,5 @@ export default createContext({ throw new Error('Registered input should be wrapped with FormWrapper'); }, }); + +export type {RegisterInput}; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index ba0f823fdbad..ad09b68a5f39 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -1,28 +1,31 @@ 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 React, {createRef, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import type {NativeSyntheticEvent, StyleProp, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; import * as ValidationUtils from '@libs/ValidationUtils'; import Visibility from '@libs/Visibility'; import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import type {OnyxFormKey} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Form, Network} from '@src/types/onyx'; -import type {FormValueType} from '@src/types/onyx/Form'; -import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type {Form} from '@src/types/form'; +import type {Network} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {RegisterInput} from './FormContext'; import FormContext from './FormContext'; import FormWrapper from './FormWrapper'; -import type {BaseInputProps, FormProps, InputRefs, OnyxFormKeyWithoutDraft, OnyxFormValues, OnyxFormValuesFields, RegisterInput, ValueTypeKey} from './types'; +import type {FormInputErrors, FormOnyxValues, FormProps, InputComponentBaseProps, InputRefs, ValueTypeKey} from './types'; // In order to prevent Checkbox focus loss when the user are focusing a TextInput and proceeds to toggle a CheckBox in web and mobile web. // 200ms delay was chosen as a result of empirical testing. // More details: https://github.com/Expensify/App/pull/16444#issuecomment-1482983426 const VALIDATE_DELAY = 200; +type GenericFormInputErrors = Partial>; type InitialDefaultValue = false | Date | ''; function getInitialValueByType(valueType?: ValueTypeKey): InitialDefaultValue { @@ -52,20 +55,26 @@ type FormProviderOnyxProps = { type FormProviderProps = FormProviderOnyxProps & FormProps & { /** Children to render. */ - children: ((props: {inputValues: OnyxFormValues}) => ReactNode) | ReactNode; + children: ((props: {inputValues: FormOnyxValues}) => ReactNode) | ReactNode; /** Callback to validate the form */ - validate?: (values: OnyxFormValuesFields) => Errors; + validate?: (values: FormOnyxValues) => FormInputErrors; /** Should validate function be called when input loose focus */ shouldValidateOnBlur?: boolean; /** 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 = { - resetForm: (optionalValue: OnyxFormValues) => void; + resetForm: (optionalValue: FormOnyxValues) => void; }; function FormProvider( @@ -84,14 +93,15 @@ function FormProvider( }: FormProviderProps, forwardedRef: ForwardedRef, ) { + const {preferredLocale} = useLocalize(); const inputRefs = useRef({}); const touchedInputs = useRef>({}); const [inputValues, setInputValues] = useState
(() => ({...draftValues})); - const [errors, setErrors] = useState({}); + const [errors, setErrors] = useState({}); const hasServerError = useMemo(() => !!formState && !isEmptyObject(formState?.errors), [formState]); const onValidate = useCallback( - (values: OnyxFormValuesFields, shouldClearServerError = true) => { + (values: FormOnyxValues, shouldClearServerError = true) => { const trimmedStringValues = ValidationUtils.prepareValues(values); if (shouldClearServerError) { @@ -99,7 +109,7 @@ function FormProvider( } FormActions.clearErrorFields(formID); - const validateErrors = validate?.(trimmedStringValues) ?? {}; + const validateErrors: GenericFormInputErrors = validate?.(trimmedStringValues) ?? {}; // Validate the input for html tags. It should supersede any other error Object.entries(trimmedStringValues).forEach(([inputID, inputValue]) => { @@ -151,6 +161,25 @@ function FormProvider( [errors, formID, validate], ); + // When locales change from another session of the same account, + // validate the form in order to update the error translations + useEffect(() => { + // Return since we only have issues with error translations + if (Object.keys(errors).length === 0) { + return; + } + + // Prepare validation values + const trimmedStringValues = ValidationUtils.prepareValues(inputValues); + + // Validate in order to make sure the correct error translations are displayed, + // making sure to not clear server errors if they exist + onValidate(trimmedStringValues, !hasServerError); + + // Only run when locales change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [preferredLocale]); + /** @param inputID - The inputID of the input being touched */ const setTouchedInput = useCallback( (inputID: keyof Form) => { @@ -185,13 +214,13 @@ function FormProvider( }, [enabledWhenOffline, formState?.isLoading, inputValues, network?.isOffline, onSubmit, onValidate]); const resetForm = useCallback( - (optionalValue: OnyxFormValuesFields) => { + (optionalValue: FormOnyxValues) => { Object.keys(inputValues).forEach((inputID) => { setInputValues((prevState) => { const copyPrevState = {...prevState}; touchedInputs.current[inputID] = false; - copyPrevState[inputID] = optionalValue[inputID as keyof OnyxFormValuesFields] || ''; + copyPrevState[inputID] = optionalValue[inputID as keyof FormOnyxValues] || ''; return copyPrevState; }); @@ -205,8 +234,8 @@ function FormProvider( })); const registerInput = useCallback( - (inputID: keyof Form, shouldSubmitForm: boolean, inputProps: TInputProps): TInputProps => { - const newRef: MutableRefObject = inputRefs.current[inputID] ?? inputProps.ref ?? createRef(); + (inputID, shouldSubmitForm, inputProps) => { + const newRef: MutableRefObject = inputRefs.current[inputID] ?? inputProps.ref ?? createRef(); if (inputRefs.current[inputID] !== newRef) { inputRefs.current[inputID] = newRef; } @@ -224,10 +253,10 @@ function FormProvider( const errorFields = formState?.errorFields?.[inputID] ?? {}; const fieldErrorMessage = - Object.keys(errorFields) + (Object.keys(errorFields) .sort() .map((key) => errorFields[key]) - .at(-1) ?? ''; + .at(-1) as string) ?? ''; const inputRef = inputProps.ref; @@ -243,7 +272,7 @@ function FormProvider( }), ref: typeof inputRef === 'function' - ? (node: BaseInputProps) => { + ? (node: InputComponentBaseProps) => { inputRef(node); newRef.current = node; } @@ -307,7 +336,7 @@ function FormProvider( } inputProps.onBlur?.(event); }, - onInputChange: (value: FormValueType, key?: string) => { + onInputChange: (value, key) => { const inputKey = key ?? inputID; setInputValues((prevState) => { const newState = { @@ -322,7 +351,7 @@ function FormProvider( }); if (inputProps.shouldSaveDraft && !formID.includes('Draft')) { - FormActions.setDraftValues(formID as OnyxFormKeyWithoutDraft, {[inputKey]: value}); + FormActions.setDraftValues(formID as OnyxFormKey, {[inputKey]: value}); } inputProps.onValueChange?.(value, inputKey); }, diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 074069ec3ea7..3a64a3df9af9 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -11,11 +11,10 @@ import type {SafeAreaChildrenProps} from '@components/SafeAreaConsumer/types'; import ScrollViewWithContext from '@components/ScrollViewWithContext'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ErrorUtils from '@libs/ErrorUtils'; -import type {Form} from '@src/types/onyx'; -import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type {Form} from '@src/types/form'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {FormProps, InputRefs} from './types'; +import type {FormInputErrors, FormProps, InputRefs} from './types'; type FormWrapperOnyxProps = { /** Contains the form state that must be accessed outside the component */ @@ -28,8 +27,11 @@ 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; + errors: FormInputErrors; /** Assuming refs are React refs */ inputRefs: RefObject; @@ -49,6 +51,7 @@ function FormWrapper({ isSubmitButtonVisible = true, style, submitButtonStyles, + submitFlexEnabled = true, enabledWhenOffline, isSubmitActionDangerous = false, formID, @@ -109,7 +112,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 +137,7 @@ function FormWrapper({ styles.mh0, styles.mt5, submitButtonStyles, + submitFlexEnabled, submitButtonText, shouldHideFixErrorsAlert, onFixTheErrorsLinkPressed, diff --git a/src/components/Form/InputWrapper.tsx b/src/components/Form/InputWrapper.tsx index fc9d1773c5d8..4d55de008516 100644 --- a/src/components/Form/InputWrapper.tsx +++ b/src/components/Form/InputWrapper.tsx @@ -1,27 +1,29 @@ -import type {ForwardedRef} from 'react'; +import type {ComponentPropsWithoutRef, ComponentType, ForwardedRef} from 'react'; import React, {forwardRef, useContext} from 'react'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import RoomNameInput from '@components/RoomNameInput'; import TextInput from '@components/TextInput'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import FormContext from './FormContext'; -import type {InputWrapperProps, ValidInputs} from './types'; +import type {InputComponentBaseProps, InputComponentValueProps, ValidInputs, ValueTypeKey} from './types'; -const textInputBasedComponents: ValidInputs[] = [TextInput, RoomNameInput]; +const textInputBasedComponents: ComponentType[] = [TextInput, RoomNameInput]; -function computeComponentSpecificRegistrationParams({ +type ComputedComponentSpecificRegistrationParams = { + shouldSubmitForm: boolean; + shouldSetTouchedOnBlurOnly: boolean; + blurOnSubmit: boolean | undefined; +}; + +function computeComponentSpecificRegistrationParams({ InputComponent, shouldSubmitForm, multiline, autoGrowHeight, blurOnSubmit, -}: InputWrapperProps): { - readonly shouldSubmitForm: boolean; - readonly blurOnSubmit: boolean | undefined; - readonly shouldSetTouchedOnBlurOnly: boolean; -} { +}: InputComponentBaseProps): ComputedComponentSpecificRegistrationParams { if (textInputBasedComponents.includes(InputComponent)) { - const isEffectivelyMultiline = Boolean(multiline) || Boolean(autoGrowHeight); + const isEffectivelyMultiline = !!multiline || !!autoGrowHeight; // If the user can use the hardware keyboard, they have access to an alternative way of inserting a new line // (like a Shift+Enter keyboard shortcut). For simplicity, we assume that when there's no touch screen, it's a @@ -31,7 +33,7 @@ function computeComponentSpecificRegistrationParams( // We want to avoid a situation when the user can't insert a new line. For single-line inputs, it's not a problem and we // force-enable form submission. For multi-line inputs, ensure that it was requested to enable form submission for this specific // input and that alternative ways exist to add a new line. - const shouldReallySubmitForm = isEffectivelyMultiline ? Boolean(shouldSubmitForm) && canUseHardwareKeyboard : true; + const shouldReallySubmitForm = isEffectivelyMultiline ? !!shouldSubmitForm && canUseHardwareKeyboard : true; return { // There are inputs that don't have onBlur methods, to simulate the behavior of onBlur in e.g. checkbox, we had to @@ -52,15 +54,28 @@ function computeComponentSpecificRegistrationParams( }; } -function InputWrapper(props: InputWrapperProps, ref: ForwardedRef) { - const {InputComponent, inputID, valueType = 'string', shouldSubmitForm: propShouldSubmitForm, ...rest} = props; +type InputWrapperProps = ComponentPropsWithoutRef & + InputComponentValueProps & { + InputComponent: TInput; + inputID: string; + isFocused?: boolean; + + /** + * Should the containing form be submitted when this input is submitted itself? + * Currently, meaningful only for text inputs. + */ + shouldSubmitForm?: boolean; + }; + +function InputWrapper(props: InputWrapperProps, ref: ForwardedRef) { + const {InputComponent, inputID, valueType = 'string', shouldSubmitForm: propShouldSubmitForm, ...rest} = props as InputComponentBaseProps; const {registerInput} = useContext(FormContext); - const {shouldSetTouchedOnBlurOnly, blurOnSubmit, shouldSubmitForm} = computeComponentSpecificRegistrationParams(props); + const {shouldSetTouchedOnBlurOnly, blurOnSubmit, shouldSubmitForm} = computeComponentSpecificRegistrationParams(props as InputComponentBaseProps); // TODO: Sometimes we return too many props with register input, so we need to consider if it's better to make the returned type more general and disregard the issue, or we would like to omit the unused props somehow. - // eslint-disable-next-line react/jsx-props-no-spreading, @typescript-eslint/no-explicit-any - return ; + // eslint-disable-next-line react/jsx-props-no-spreading + return ; } InputWrapper.displayName = 'InputWrapper'; diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 353a6927caf7..93d9df12456c 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -1,68 +1,81 @@ -import type {ComponentProps, FocusEvent, Key, MutableRefObject, ReactNode, Ref} from 'react'; +import type {ComponentType, FocusEvent, Key, MutableRefObject, ReactNode, Ref} from 'react'; import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; 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 CountrySelector from '@components/CountrySelector'; 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 {TranslationPaths} from '@src/languages/types'; import type {OnyxFormKey, OnyxValues} from '@src/ONYXKEYS'; -import type Form from '@src/types/onyx/Form'; -import type {BaseForm, FormValueType} from '@src/types/onyx/Form'; +import type {BaseForm} from '@src/types/form/Form'; /** * This type specifies all the inputs that can be used with `InputWrapper` component. Make sure to update it * when adding new inputs or removing old ones. * * TODO: Add remaining inputs here once these components are migrated to Typescript: - * CountrySelector | StatePicker | DatePicker | EmojiPickerButtonDropdown | RoomNameInput | ValuePicker + * 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 CountrySelector + | typeof AmountForm + | typeof BusinessTypePicker + | typeof StatePicker; type ValueTypeKey = 'string' | 'boolean' | 'date'; +type ValueTypeMap = { + string: string; + boolean: boolean; + date: Date; +}; +type FormValue = ValueOf; -type MeasureLayoutOnSuccessCallback = (left: number, top: number, width: number, height: number) => void; +type InputComponentValueProps = { + valueType?: TValue; + value?: ValueTypeMap[TValue]; + defaultValue?: ValueTypeMap[TValue]; + onValueChange?: (value: ValueTypeMap[TValue], key: string) => void; + shouldSaveDraft?: boolean; + shouldUseDefaultValue?: boolean; +}; -type BaseInputProps = { +type MeasureLayoutOnSuccessCallback = (left: number, top: number, width: number, height: number) => void; +type InputComponentBaseProps = InputComponentValueProps & { + InputComponent: ComponentType; + inputID: string; + errorText?: string; shouldSetTouchedOnBlurOnly?: boolean; - onValueChange?: (value: unknown, key: string) => void; + isFocused?: boolean; + measureLayout?: (ref: unknown, callback: MeasureLayoutOnSuccessCallback) => void; + focus?: () => void; onTouched?: (event: GestureResponderEvent) => void; - valueType?: ValueTypeKey; - value?: FormValueType; - defaultValue?: FormValueType; onBlur?: (event: FocusEvent | NativeSyntheticEvent) => void; onPressOut?: (event: GestureResponderEvent) => void; onPress?: (event: GestureResponderEvent) => void; - shouldSaveDraft?: boolean; - shouldUseDefaultValue?: boolean; - key?: Key | null | undefined; + onInputChange?: (value: FormValue, key: string) => void; + onSubmitEditing?: (event: NativeSyntheticEvent) => void; + key?: Key; ref?: Ref; - isFocused?: boolean; - measureLayout?: (ref: unknown, callback: MeasureLayoutOnSuccessCallback) => void; - focus?: () => void; multiline?: boolean; autoGrowHeight?: boolean; blurOnSubmit?: boolean; - onSubmitEditing?: (event: NativeSyntheticEvent) => void; + shouldSubmitForm?: boolean; }; -type InputWrapperProps = Omit & - ComponentProps & { - InputComponent: TInput; - inputID: string; - - /** - * Should the containing form be submitted when this input is submitted itself? - * Currently, meaningful only for text inputs. - */ - shouldSubmitForm?: boolean; - }; - -type ExcludeDraft = T extends `${string}Draft` ? never : T; -type OnyxFormKeyWithoutDraft = ExcludeDraft; - -type OnyxFormValues = OnyxValues[TOnyxKey]; -type OnyxFormValuesFields = Omit, keyof BaseForm>; +type FormOnyxValues = Omit; +type FormOnyxKeys = keyof FormOnyxValues; type FormProps = { /** A unique Onyx key identifying the form */ @@ -71,11 +84,14 @@ 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; /** Callback to submit the form */ - onSubmit: (values: OnyxFormValuesFields) => void; + onSubmit: (values: FormOnyxValues) => void; /** Should the button be enabled when offline */ enabledWhenOffline?: boolean; @@ -99,8 +115,8 @@ type FormProps = { disablePressOnEnter?: boolean; }; -type RegisterInput = (inputID: keyof Form, shouldSubmitForm: boolean, inputProps: TInputProps) => TInputProps; +type InputRefs = Record>; -type InputRefs = Record>; +type FormInputErrors = Partial, TranslationPaths>>; -export type {InputWrapperProps, FormProps, RegisterInput, ValidInputs, BaseInputProps, ValueTypeKey, OnyxFormValues, OnyxFormValuesFields, InputRefs, OnyxFormKeyWithoutDraft}; +export type {FormProps, ValidInputs, InputComponentValueProps, FormValue, ValueTypeKey, FormOnyxValues, FormOnyxKeys, FormInputErrors, InputRefs, InputComponentBaseProps, ValueTypeMap}; 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 ( { + const clearMemoryCache = () => Image.clearMemoryCache(); + return () => { + clearMemoryCache(); + }; + }, []); + + return ( + + ); +} + +ImageSVG.displayName = 'ImageSVG'; +export default ImageSVG; diff --git a/src/components/ImageSVG/index.native.tsx b/src/components/ImageSVG/index.ios.tsx similarity index 100% rename from src/components/ImageSVG/index.native.tsx rename to src/components/ImageSVG/index.ios.tsx diff --git a/src/components/InteractiveStepSubHeader.tsx b/src/components/InteractiveStepSubHeader.tsx new file mode 100644 index 000000000000..074e10d521cf --- /dev/null +++ b/src/components/InteractiveStepSubHeader.tsx @@ -0,0 +1,110 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useImperativeHandle, useState} from 'react'; +import type {ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import colors from '@styles/theme/colors'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import Icon from './Icon'; +import * as Expensicons from './Icon/Expensicons'; +import PressableWithFeedback from './Pressable/PressableWithFeedback'; +import Text from './Text'; + +type InteractiveStepSubHeaderProps = { + /** List of the Route Name to navigate when the step is selected */ + stepNames: readonly string[]; + + /** Function to call when a step is selected */ + onStepSelected?: (stepName: string) => 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/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index ab2d217deb0e..b43e5265a970 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -281,6 +281,7 @@ export default withOnyx({ bankAccountList: { key: ONYXKEYS.BANK_ACCOUNT_LIST, }, + // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM reimbursementAccount: { key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, }, diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 81ab1ae33268..27f424ad1b70 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -1,14 +1,12 @@ import {FlashList} from '@shopify/flash-list'; import type {ReactElement} from 'react'; -import React, {useCallback} from 'react'; +import React, {memo, useCallback} from 'react'; import {StyleSheet, View} from 'react-native'; 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} @@ -162,7 +158,7 @@ export default withCurrentReportID( transactionViolations: { key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, }, - })(LHNOptionsList), + })(memo(LHNOptionsList)), ); export type {LHNOptionsListProps}; 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/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index bfe5a7ff2a75..0e08aed214a6 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -57,10 +57,9 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money const isAutoReimbursable = ReportUtils.canBeAutoReimbursed(moneyRequestReport, policy); const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicy(moneyRequestReport); const isManager = ReportUtils.isMoneyRequestReport(moneyRequestReport) && session?.accountID === moneyRequestReport.managerID; - const isReimburser = session?.email === policy?.reimburserEmail; const isPayer = isPaidGroupPolicy ? // In a group policy, the admin approver can pay the report directly by skipping the approval step - isReimburser && (isApproved || isManager) + isPolicyAdmin && (isApproved || isManager) : isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager); const isDraft = ReportUtils.isDraftExpenseReport(moneyRequestReport); const isOnInstantSubmitPolicy = PolicyUtils.isInstantSubmitEnabled(policy); diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index 537769d1ebf6..ec4f0f9cf5f8 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'; @@ -316,6 +317,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const isMerchantEmpty = !iouMerchant || iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; const isMerchantRequired = isPolicyExpenseChat && !isScanRequest && shouldShowMerchant; + const isCategoryRequired = canUseViolations && Boolean(policy.requiresCategory); + const isTagRequired = canUseViolations && Boolean(policy.requiresTag); + useEffect(() => { if ((!isMerchantRequired && isMerchantEmpty) || !merchantError) { return; @@ -549,6 +553,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ return; } + playSound(SOUNDS.DONE); setDidConfirm(true); onConfirm(selectedParticipants); } @@ -624,6 +629,226 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ ); }, [isReadOnly, iouType, selectedParticipants.length, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, formError, styles.ph1, styles.mb2]); + // An intermediate structure that helps us classify the fields as "primary" and "supplementary". + // The primary fields are always shown to the user, while an extra action is needed to reveal the supplementary ones. + const classifiedFields = [ + { + item: ( + { + if (isDistanceRequest) { + return; + } + if (isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.AMOUNT)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + style={[styles.moneyRequestMenuItem, styles.mt2]} + titleStyle={styles.moneyRequestConfirmationAmount} + disabled={didConfirm} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} + /> + ), + shouldShow: shouldShowSmartScanFields, + isSupplementary: false, + }, + { + item: ( + { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ); + }} + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + disabled={didConfirm} + interactive={!isReadOnly} + numberOfLinesTitle={2} + /> + ), + shouldShow: true, + isSupplementary: false, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm || !isTypeRequest} + interactive={!isReadOnly} + /> + ), + shouldShow: isDistanceRequest, + isSupplementary: true, + }, + { + item: ( + { + if (isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.MERCHANT)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={merchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={merchantError ? translate('common.error.fieldRequired') : ''} + /> + ), + shouldShow: shouldShowMerchant, + isSupplementary: !isMerchantRequired, + }, + { + item: ( + { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} + /> + ), + shouldShow: shouldShowDate, + isSupplementary: true, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + disabled={didConfirm} + interactive={!isReadOnly} + rightLabel={canUseViolations && Boolean(policy.requiresCategory) ? translate('common.required') : ''} + /> + ), + shouldShow: shouldShowCategories, + isSupplementary: !isCategoryRequired, + }, + { + item: ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ) + } + style={[styles.moneyRequestMenuItem]} + disabled={didConfirm} + interactive={!isReadOnly} + rightLabel={canUseViolations && lodashGet(policy, 'requiresTag', false) ? translate('common.required') : ''} + /> + ), + shouldShow: shouldShowTags, + isSupplementary: !isTagRequired, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + interactive={!isReadOnly} + /> + ), + shouldShow: shouldShowTax, + isSupplementary: true, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + interactive={!isReadOnly} + /> + ), + shouldShow: shouldShowTax, + isSupplementary: true, + }, + { + item: ( + + {translate('common.billable')} + + + ), + shouldShow: shouldShowBillable, + isSupplementary: true, + }, + ]; + + const primaryFields = _.map( + _.filter(classifiedFields, (classifiedField) => classifiedField.shouldShow && !classifiedField.isSupplementary), + (primaryField) => primaryField.item, + ); + + const supplementaryFields = _.map( + _.filter(classifiedFields, (classifiedField) => classifiedField.shouldShow && classifiedField.isSupplementary), + (supplementaryField) => supplementaryField.item, + ); + const {image: receiptImage, thumbnail: receiptThumbnail} = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : {}; return ( ) )} - {shouldShowSmartScanFields && ( - { - if (isDistanceRequest) { - return; - } - if (isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.AMOUNT)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); - }} - style={[styles.moneyRequestMenuItem, styles.mt2]} - titleStyle={styles.moneyRequestConfirmationAmount} - disabled={didConfirm} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - error={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} - /> - )} - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), - ); - }} - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - disabled={didConfirm} - interactive={!isReadOnly} - numberOfLinesTitle={2} - /> - {isMerchantRequired && ( - { - if (isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.MERCHANT)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); - }} - disabled={didConfirm} - interactive={!isReadOnly} - brickRoadIndicator={merchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - error={merchantError ? translate('common.error.fieldRequired') : ''} - /> - )} + {primaryFields} {!shouldShowAllFields && ( @@ -746,134 +913,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ )} - {shouldShowAllFields && ( - <> - {isDistanceRequest && ( - - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())) - } - disabled={didConfirm || !isTypeRequest} - interactive={!isReadOnly} - /> - )} - {!isMerchantRequired && shouldShowMerchant && ( - { - if (isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.MERCHANT)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); - }} - disabled={didConfirm} - interactive={!isReadOnly} - brickRoadIndicator={merchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - error={merchantError ? translate('common.error.fieldRequired') : ''} - /> - )} - {shouldShowDate && ( - { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), - ); - }} - disabled={didConfirm} - interactive={!isReadOnly} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} - /> - )} - {shouldShowCategories && ( - - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())) - } - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - disabled={didConfirm} - interactive={!isReadOnly} - rightLabel={canUseViolations && Boolean(policy.requiresCategory) ? translate('common.required') : ''} - /> - )} - {shouldShowTags && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), - ) - } - style={[styles.moneyRequestMenuItem]} - disabled={didConfirm} - interactive={!isReadOnly} - rightLabel={canUseViolations && lodashGet(policy, 'requiresTag', false) ? translate('common.required') : ''} - /> - )} - {shouldShowTax && ( - - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())) - } - disabled={didConfirm} - interactive={!isReadOnly} - /> - )} - - {shouldShowTax && ( - - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())) - } - disabled={didConfirm} - interactive={!isReadOnly} - /> - )} - {shouldShowBillable && ( - - {translate('common.billable')} - - - )} - - )} + {shouldShowAllFields && <>{supplementaryFields}} ); } diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index 2c41864564a3..ba9ce9858d03 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -24,7 +24,7 @@ import MessagesRow from './MessagesRow'; type OfflineWithFeedbackProps = ChildrenProps & { /** The type of action that's pending */ - pendingAction?: OnyxCommon.PendingAction; + pendingAction?: OnyxCommon.PendingAction | null; /** Determine whether to hide the component's children if deletion is pending */ shouldHideOnDelete?: boolean; diff --git a/src/components/OnyxProvider.tsx b/src/components/OnyxProvider.tsx index 124f3558df90..d14aec90fa10 100644 --- a/src/components/OnyxProvider.tsx +++ b/src/components/OnyxProvider.tsx @@ -10,11 +10,12 @@ const [withPersonalDetails, PersonalDetailsProvider, , usePersonalDetails] = cre const [withCurrentDate, CurrentDateProvider] = createOnyxContext(ONYXKEYS.CURRENT_DATE); const [withReportActionsDrafts, ReportActionsDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS); const [withBlockedFromConcierge, BlockedFromConciergeProvider] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); -const [withBetas, BetasProvider, BetasContext] = createOnyxContext(ONYXKEYS.BETAS); +const [withBetas, BetasProvider, BetasContext, useBetas] = createOnyxContext(ONYXKEYS.BETAS); const [withReportCommentDrafts, ReportCommentDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [withPreferredTheme, PreferredThemeProvider, PreferredThemeContext] = createOnyxContext(ONYXKEYS.PREFERRED_THEME); const [withFrequentlyUsedEmojis, FrequentlyUsedEmojisProvider, , useFrequentlyUsedEmojis] = createOnyxContext(ONYXKEYS.FREQUENTLY_USED_EMOJIS); const [withPreferredEmojiSkinTone, PreferredEmojiSkinToneProvider, PreferredEmojiSkinToneContext] = createOnyxContext(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); +const [, SessionProvider, , useSession] = createOnyxContext(ONYXKEYS.SESSION); type OnyxProviderProps = { /** Rendered child component */ @@ -35,6 +36,7 @@ function OnyxProvider(props: OnyxProviderProps) { PreferredThemeProvider, FrequentlyUsedEmojisProvider, PreferredEmojiSkinToneProvider, + SessionProvider, ]} > {props.children} @@ -59,8 +61,10 @@ export { withReportCommentDrafts, withPreferredTheme, PreferredThemeContext, + useBetas, withFrequentlyUsedEmojis, useFrequentlyUsedEmojis, withPreferredEmojiSkinTone, PreferredEmojiSkinToneContext, + useSession, }; diff --git a/src/components/OptionsList/BaseOptionsList.tsx b/src/components/OptionsList/BaseOptionsList.tsx index 4a9df89ae644..575df128894a 100644 --- a/src/components/OptionsList/BaseOptionsList.tsx +++ b/src/components/OptionsList/BaseOptionsList.tsx @@ -1,6 +1,6 @@ import isEqual from 'lodash/isEqual'; import type {ForwardedRef} from 'react'; -import React, {forwardRef, memo, useEffect, useRef} from 'react'; +import React, {forwardRef, memo, useEffect, useMemo, useRef} from 'react'; import type {SectionListRenderItem} from 'react-native'; import {View} from 'react-native'; import OptionRow from '@components/OptionRow'; @@ -31,7 +31,7 @@ function BaseOptionsList( shouldHaveOptionSeparator = false, showTitleTooltip = false, optionHoveredStyle, - contentContainerStyles, + contentContainerStyles: contentContainerStylesProp, sectionHeaderStyle, showScrollIndicator = true, listContainerStyles: listContainerStylesProp, @@ -51,6 +51,7 @@ function BaseOptionsList( nestedScrollEnabled = true, bounces = true, renderFooterContent, + safeAreaPaddingBottomStyle, }: BaseOptionListProps, ref: ForwardedRef, ) { @@ -64,7 +65,8 @@ function BaseOptionsList( const previousSections = usePrevious(sections); const didLayout = useRef(false); - const listContainerStyles = listContainerStylesProp ?? [styles.flex1]; + const listContainerStyles = useMemo(() => listContainerStylesProp ?? [styles.flex1], [listContainerStylesProp, styles.flex1]); + const contentContainerStyles = useMemo(() => [safeAreaPaddingBottomStyle, contentContainerStylesProp], [contentContainerStylesProp, safeAreaPaddingBottomStyle]); /** * This helper function is used to memoize the computation needed for getItemLayout. It is run whenever section data changes. diff --git a/src/components/OptionsList/index.native.tsx b/src/components/OptionsList/index.native.tsx index bdcd0418a940..d4977f25f366 100644 --- a/src/components/OptionsList/index.native.tsx +++ b/src/components/OptionsList/index.native.tsx @@ -10,7 +10,7 @@ function OptionsList(props: OptionsListProps, ref: ForwardedRef // eslint-disable-next-line react/jsx-props-no-spreading {...props} ref={ref} - onScrollBeginDrag={() => Keyboard.dismiss()} + onScrollBeginDrag={Keyboard.dismiss} /> ); } diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index cb6a2dcbe722..26016bd51ec0 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -113,19 +113,19 @@ class BaseOptionsSelector extends Component { componentDidUpdate(prevProps, prevState) { if (prevState.disableEnterShortCut !== this.state.disableEnterShortCut) { - if (this.state.disableEnterShortCut) { - this.unsubscribeEnter(); - } else { + // Unregister the shortcut before registering a new one to avoid lingering shortcut listener + this.unsubscribeEnter(); + if (!this.state.disableEnterShortCut) { this.subscribeToEnterShortcut(); } } if (prevProps.isFocused !== this.props.isFocused) { + // Unregister the shortcut before registering a new one to avoid lingering shortcut listener + this.unSubscribeFromKeyboardShortcut(); if (this.props.isFocused) { this.subscribeToEnterShortcut(); this.subscribeToCtrlEnterShortcut(); - } else { - this.unSubscribeFromKeyboardShortcut(); } } 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/MoneyRequestPreview.tsx b/src/components/ReportActionItem/MoneyRequestPreview.tsx index e89193108d24..7577c6b547e4 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview.tsx @@ -325,7 +325,7 @@ function MoneyRequestPreview({ {!isCurrentUserManager && shouldShowPendingConversionMessage && ( {translate('iou.pendingConversionMessage')} )} - {shouldShowDescription && } + {shouldShowDescription && ${parser.replace(merchantOrDescription)}`} />} {shouldShowMerchant && {merchantOrDescription}} {isBillSplit && participantAccountIDs.length > 0 && !!requestAmount && requestAmount > 0 && ( diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index bec6990f8e4f..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 && } )} @@ -317,10 +350,9 @@ function MoneyRequestView({ onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) } - brickRoadIndicator={hasViolations('date') || (hasErrors && transactionDate === '') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''} + brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('date')} /> - {canUseViolations && } {shouldShowCategory && ( @@ -331,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 && ( @@ -347,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 && ( @@ -371,13 +403,13 @@ function MoneyRequestView({ isOn={!!transactionBillable} onToggle={saveBillable} /> + {getErrorForField('billable') && ( + + )} - {hasViolations('billable') && ( - - )} )} diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 4066776711b1..cfe06b2c0a62 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -122,7 +122,6 @@ function ReportPreview({ const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); const policyType = policy?.type; const isAutoReimbursable = ReportUtils.canBeAutoReimbursed(iouReport, policy); - const isReimburser = session?.email === policy?.reimburserEmail; const iouSettled = ReportUtils.isSettled(iouReportID); const iouCanceled = ReportUtils.isArchivedRoom(chatReport); @@ -213,7 +212,7 @@ function ReportPreview({ const isPolicyAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && policy?.role === CONST.POLICY.ROLE.ADMIN; const isPayer = isPaidGroupPolicy ? // In a paid group policy, the admin approver can pay the report directly by skipping the approval step - isReimburser && (isApproved || isCurrentUserManager) + isPolicyAdmin && (isApproved || isCurrentUserManager) : isPolicyAdmin || (isMoneyRequestReport && isCurrentUserManager); const isOnInstantSubmitPolicy = PolicyUtils.isInstantSubmitEnabled(policy); const isOnSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); 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/createOnyxContext.tsx b/src/components/createOnyxContext.tsx index 4fb8b9a7281c..50cda00b17b4 100644 --- a/src/components/createOnyxContext.tsx +++ b/src/components/createOnyxContext.tsx @@ -3,42 +3,40 @@ import type {ComponentType, ForwardedRef, ForwardRefExoticComponent, PropsWithou import React, {createContext, forwardRef, useContext} from 'react'; import {withOnyx} from 'react-native-onyx'; import getComponentDisplayName from '@libs/getComponentDisplayName'; -import type {OnyxCollectionKey, OnyxKey, OnyxKeyValue, OnyxValues} from '@src/ONYXKEYS'; +import type {OnyxKey, OnyxValue, OnyxValues} from '@src/ONYXKEYS'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -type OnyxKeys = (OnyxKey | OnyxCollectionKey) & keyof OnyxValues; - // Provider types -type ProviderOnyxProps = Record>; +type ProviderOnyxProps = Record>; -type ProviderPropsWithOnyx = ChildrenProps & ProviderOnyxProps; +type ProviderPropsWithOnyx = ChildrenProps & ProviderOnyxProps; // withOnyxKey types -type WithOnyxKeyProps = { +type WithOnyxKeyProps = { propName?: TOnyxKey | TNewOnyxKey; // It's not possible to infer the type of props of the wrapped component, so we have to use `any` here // eslint-disable-next-line @typescript-eslint/no-explicit-any - transformValue?: (value: OnyxKeyValue, props: any) => TTransformedValue; + transformValue?: (value: OnyxValue, props: any) => TTransformedValue; }; type WrapComponentWithConsumer = , TRef>( WrappedComponent: ComponentType>, ) => ForwardRefExoticComponent> & RefAttributes>; -type WithOnyxKey = >( +type WithOnyxKey = >( props?: WithOnyxKeyProps, ) => WrapComponentWithConsumer; // createOnyxContext return type -type CreateOnyxContext = [ +type CreateOnyxContext = [ WithOnyxKey, ComponentType, TOnyxKey>>, - React.Context>, + React.Context>, () => OnyxValues[TOnyxKey], ]; -export default (onyxKeyName: TOnyxKey): CreateOnyxContext => { - const Context = createContext>(null); +export default (onyxKeyName: TOnyxKey): CreateOnyxContext => { + const Context = createContext>(null); function Provider(props: ProviderPropsWithOnyx): ReactNode { return {props.children}; } @@ -52,7 +50,7 @@ export default (onyxKeyName: TOnyxKey): CreateOnyxCon // eslint-disable-next-line @typescript-eslint/no-explicit-any } as Record)(Provider); - function withOnyxKey>({ + function withOnyxKey>({ propName, transformValue, }: WithOnyxKeyProps = {}) { diff --git a/src/components/withCurrentUserPersonalDetails.tsx b/src/components/withCurrentUserPersonalDetails.tsx index 9406c8634c1b..313bcad74f35 100644 --- a/src/components/withCurrentUserPersonalDetails.tsx +++ b/src/components/withCurrentUserPersonalDetails.tsx @@ -1,26 +1,17 @@ import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; -import React, {useMemo} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import React from 'react'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import personalDetailsPropType from '@pages/personalDetailsPropType'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Session} from '@src/types/onyx'; -import {usePersonalDetails} from './OnyxProvider'; +import type {PersonalDetails} from '@src/types/onyx'; type CurrentUserPersonalDetails = PersonalDetails | Record; -type OnyxProps = { - /** Session of the current user */ - session: OnyxEntry; -}; - type HOCProps = { currentUserPersonalDetails: CurrentUserPersonalDetails; }; -type WithCurrentUserPersonalDetailsProps = OnyxProps & HOCProps; +type WithCurrentUserPersonalDetailsProps = HOCProps; // TODO: remove when all components that use it will be migrated to TS const withCurrentUserPersonalDetailsPropTypes = { @@ -33,15 +24,9 @@ const withCurrentUserPersonalDetailsDefaultProps: HOCProps = { export default function ( WrappedComponent: ComponentType>, -): ComponentType & RefAttributes, keyof OnyxProps>> { +): ComponentType & RefAttributes> { function WithCurrentUserPersonalDetails(props: Omit, ref: ForwardedRef) { - const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT; - const accountID = props.session?.accountID ?? 0; - const accountPersonalDetails = personalDetails?.[accountID]; - const currentUserPersonalDetails: CurrentUserPersonalDetails = useMemo( - () => (accountPersonalDetails ? {...accountPersonalDetails, accountID} : {}) as CurrentUserPersonalDetails, - [accountPersonalDetails, accountID], - ); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); return ( & RefAttributes, OnyxProps>({ - session: { - key: ONYXKEYS.SESSION, - }, - })(withCurrentUserPersonalDetails); + return React.forwardRef(WithCurrentUserPersonalDetails); } export {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps}; diff --git a/src/hooks/useActiveElementRole/index.native.ts b/src/hooks/useActiveElementRole/index.native.ts deleted file mode 100644 index 4278014f02a8..000000000000 --- a/src/hooks/useActiveElementRole/index.native.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type UseActiveElementRole from './types'; - -/** - * Native doesn't have the DOM, so we just return null. - */ -const useActiveElementRole: UseActiveElementRole = () => null; - -export default useActiveElementRole; diff --git a/src/hooks/useActiveElementRole/index.ts b/src/hooks/useActiveElementRole/index.ts index a98999105ac8..98ae285f92b0 100644 --- a/src/hooks/useActiveElementRole/index.ts +++ b/src/hooks/useActiveElementRole/index.ts @@ -1,4 +1,5 @@ -import {useEffect, useRef} from 'react'; +import {useContext} from 'react'; +import {ActiveElementRoleContext} from '@components/ActiveElementRoleProvider'; import type UseActiveElementRole from './types'; /** @@ -6,27 +7,9 @@ import type UseActiveElementRole from './types'; * On native, we just return null. */ const useActiveElementRole: UseActiveElementRole = () => { - const activeRoleRef = useRef(document?.activeElement?.role); + const {role} = useContext(ActiveElementRoleContext); - const handleFocusIn = () => { - activeRoleRef.current = document?.activeElement?.role; - }; - - const handleFocusOut = () => { - activeRoleRef.current = null; - }; - - useEffect(() => { - document.addEventListener('focusin', handleFocusIn); - document.addEventListener('focusout', handleFocusOut); - - return () => { - document.removeEventListener('focusin', handleFocusIn); - document.removeEventListener('focusout', handleFocusOut); - }; - }, []); - - return activeRoleRef.current; + return role; }; export default useActiveElementRole; diff --git a/src/hooks/useActiveElementRole/types.ts b/src/hooks/useActiveElementRole/types.ts index c31b8ab7ddbf..f6884548785f 100644 --- a/src/hooks/useActiveElementRole/types.ts +++ b/src/hooks/useActiveElementRole/types.ts @@ -1,3 +1,3 @@ -type UseActiveElementRole = () => string | null | undefined; +type UseActiveElementRole = () => string | null; export default UseActiveElementRole; diff --git a/src/hooks/useArrowKeyFocusManager.ts b/src/hooks/useArrowKeyFocusManager.ts index 352734c92e8d..78ffc7f87209 100644 --- a/src/hooks/useArrowKeyFocusManager.ts +++ b/src/hooks/useArrowKeyFocusManager.ts @@ -11,6 +11,7 @@ type Config = { isActive?: boolean; itemsPerRow?: number; disableCyclicTraversal?: boolean; + disableHorizontalKeys?: boolean; }; type UseArrowKeyFocusManager = [number, (index: number) => 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/useCurrentUserPersonalDetails.ts b/src/hooks/useCurrentUserPersonalDetails.ts new file mode 100644 index 000000000000..da3c2b18bd83 --- /dev/null +++ b/src/hooks/useCurrentUserPersonalDetails.ts @@ -0,0 +1,21 @@ +import {useMemo} from 'react'; +import {usePersonalDetails, useSession} from '@components/OnyxProvider'; +import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; + +type CurrentUserPersonalDetails = PersonalDetails | Record; + +function useCurrentUserPersonalDetails() { + const session = useSession(); + const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT; + const accountID = session?.accountID ?? 0; + const accountPersonalDetails = personalDetails?.[accountID]; + const currentUserPersonalDetails: CurrentUserPersonalDetails = useMemo( + () => (accountPersonalDetails ? {...accountPersonalDetails, accountID} : {}) as CurrentUserPersonalDetails, + [accountPersonalDetails, accountID], + ); + + return currentUserPersonalDetails; +} + +export default useCurrentUserPersonalDetails; diff --git a/src/hooks/useIsSplashHidden.ts b/src/hooks/useIsSplashHidden.ts new file mode 100644 index 000000000000..7563d388416c --- /dev/null +++ b/src/hooks/useIsSplashHidden.ts @@ -0,0 +1,11 @@ +import {useContext} from 'react'; +import {SplashScreenHiddenContext} from '@src/Expensify'; + +type SplashScreenHiddenContextType = {isSplashHidden: boolean}; + +export default function useIsSplashHidden() { + const {isSplashHidden} = useContext(SplashScreenHiddenContext) as SplashScreenHiddenContextType; + return isSplashHidden; +} + +export type {SplashScreenHiddenContextType}; 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..f3a17447c7d7 --- /dev/null +++ b/src/hooks/useReimbursementAccountStepFormSubmit.ts @@ -0,0 +1,45 @@ +import {useCallback} from 'react'; +import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import * as FormActions from '@userActions/FormActions'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SubStepProps} from './useSubStep/types'; + +type UseReimbursementAccountStepFormSubmitParams = Pick & { + formId?: OnyxFormKey; + 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: FormOnyxValues) => { + 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/useResponsiveLayout.ts b/src/hooks/useResponsiveLayout.ts index f00890116d47..75e20ed9b5e0 100644 --- a/src/hooks/useResponsiveLayout.ts +++ b/src/hooks/useResponsiveLayout.ts @@ -12,7 +12,7 @@ type ResponsiveLayoutResult = { */ export default function useResponsiveLayout(): ResponsiveLayoutResult { const {isSmallScreenWidth} = useWindowDimensions(); - const state = navigationRef?.getRootState(); + const state = navigationRef?.current?.getRootState(); const lastRoute = state?.routes?.at(-1); const lastRouteName = lastRoute?.name; const isInModal = lastRouteName === NAVIGATORS.LEFT_MODAL_NAVIGATOR || lastRouteName === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; 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..6d853604aa20 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: 'Elige una unidad predeterminada de medida.', + 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..6cf2735beabc --- /dev/null +++ b/src/libs/API/parameters/AcceptACHContractForBankAccount.ts @@ -0,0 +1,5 @@ +import type {ACHContractStepProps} from '@src/types/form/ReimbursementAccountForm'; + +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..4d4e1af87e3b 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'; +import type {ACHContractStepProps} from '@src/types/form/ReimbursementAccountForm'; -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..324c7070bbe2 100644 --- a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts @@ -1,8 +1,8 @@ -import type {BankAccountStepProps, CompanyStepProps, ReimbursementAccountProps} from '@src/types/onyx/ReimbursementAccountDraft'; +import type {BankAccountStepProps, CompanyStepProps, ReimbursementAccountProps} from '@src/types/form/ReimbursementAccountForm'; 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..b9c2ce65405b 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'; +import type {RequestorStepProps} from '@src/types/form/ReimbursementAccountForm'; -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/FormUtils.ts b/src/libs/FormUtils.ts index 37241df49af7..4d0571ada6f2 100644 --- a/src/libs/FormUtils.ts +++ b/src/libs/FormUtils.ts @@ -1,6 +1,6 @@ -import type {OnyxFormKeyWithoutDraft} from '@components/Form/types'; +import type {OnyxFormDraftKey, OnyxFormKey} from '@src/ONYXKEYS'; -function getDraftKey(formID: OnyxFormKeyWithoutDraft): `${OnyxFormKeyWithoutDraft}Draft` { +function getDraftKey(formID: OnyxFormKey): OnyxFormDraftKey { return `${formID}Draft`; } diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts index 82d991efb3aa..cf49ba03f287 100644 --- a/src/libs/GetPhysicalCardUtils.ts +++ b/src/libs/GetPhysicalCardUtils.ts @@ -1,7 +1,8 @@ import type {OnyxEntry} from 'react-native-onyx'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; -import type {GetPhysicalCardForm, LoginList, PrivatePersonalDetails} from '@src/types/onyx'; +import type {GetPhysicalCardForm} from '@src/types/form'; +import type {LoginList, PrivatePersonalDetails} from '@src/types/onyx'; import Navigation from './Navigation/Navigation'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import * as UserUtils from './UserUtils'; 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 be7cce22ec6c..4723bbfd9b4e 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -237,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/getTopmostReportId.ts b/src/libs/Navigation/getTopmostReportId.ts index 4438cb273b84..aac8e7654865 100644 --- a/src/libs/Navigation/getTopmostReportId.ts +++ b/src/libs/Navigation/getTopmostReportId.ts @@ -16,7 +16,7 @@ function getTopmostReportId(state: NavigationState | NavigationState route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR).at(-1); + const topmostCentralPane = state.routes?.filter((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR).at(-1); if (!topmostCentralPane) { return; } 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 e6ee00064d95..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, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 2af17eab499a..7e6a13ec7ffc 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; }; @@ -204,7 +212,9 @@ type RoomMembersNavigatorParamList = { }; type RoomInviteNavigatorParamList = { - [SCREENS.ROOM_INVITE_ROOT]: undefined; + [SCREENS.ROOM_INVITE_ROOT]: { + reportID: string; + }; }; type MoneyRequestNavigatorParamList = { @@ -323,7 +333,12 @@ type SplitDetailsNavigatorParamList = { [SCREENS.SPLIT_DETAILS.ROOT]: { reportActionID: string; }; - [SCREENS.SPLIT_DETAILS.EDIT_REQUEST]: undefined; + [SCREENS.SPLIT_DETAILS.EDIT_REQUEST]: { + field: string; + reportID: string; + reportActionID: string; + currency: string; + }; [SCREENS.SPLIT_DETAILS.EDIT_CURRENCY]: undefined; }; @@ -339,7 +354,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 = { @@ -444,6 +462,7 @@ type PublicScreensParamList = { [SCREENS.VALIDATE_LOGIN]: { accountID: string; validateCode: string; + exitTo?: Routes | HybridAppRoute; }; [SCREENS.UNLINK_LOGIN]: { accountID?: string; 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/OnyxSelectors/reportWithoutHasDraftSelector.ts b/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts index 1062aa4b45c6..9c7e6402d69b 100644 --- a/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts +++ b/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts @@ -1,6 +1,6 @@ -import type {OnyxKeyValue} from '@src/ONYXKEYS'; +import type {OnyxValue} from '@src/ONYXKEYS'; -export default function reportWithoutHasDraftSelector(report: OnyxKeyValue<'report_'>) { +export default function reportWithoutHasDraftSelector(report: OnyxValue<'report_'>) { if (!report) { return report; } diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 046a66a8d6e0..346cc71953e6 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -5,7 +5,6 @@ import lodashGet from 'lodash/get'; import lodashOrderBy from 'lodash/orderBy'; import lodashSet from 'lodash/set'; import lodashSortBy from 'lodash/sortBy'; -import type {ReactElement} from 'react'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; @@ -122,11 +121,11 @@ type MemberForList = { keyForList: string; isSelected: boolean; isDisabled: boolean; - accountID?: number; + accountID?: number | null; login: string; - rightElement: ReactElement | null; icons?: OnyxCommon.Icon[]; pendingAction?: OnyxCommon.PendingAction; + reportID: string; }; type SectionForSearchTerm = { @@ -1756,7 +1755,7 @@ function getIOUConfirmationOptionsFromParticipants(participants: Participant[], * Build the options for the New Group view */ function getFilteredOptions( - reports: Record, + reports: OnyxCollection, personalDetails: OnyxEntry, betas: Beta[] = [], searchValue = '', @@ -1837,14 +1836,8 @@ function getShareDestinationOptions( * @param member - personalDetails or userToInvite * @param config - keys to overwrite the default values */ -function formatMemberForList(member: ReportUtils.OptionData, config?: Partial): MemberForList; -function formatMemberForList(member: null | undefined, config?: Partial): undefined; -function formatMemberForList(member: ReportUtils.OptionData | null | undefined, config: Partial = {}): MemberForList | undefined { - if (!member) { - return undefined; - } - - const accountID = member.accountID ?? undefined; +function formatMemberForList(member: ReportUtils.OptionData): MemberForList { + const accountID = member.accountID; return { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -1853,14 +1846,13 @@ function formatMemberForList(member: ReportUtils.OptionData | null | undefined, alternateText: member.alternateText || member.login || '', // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing keyForList: member.keyForList || String(accountID ?? 0) || '', - isSelected: false, - isDisabled: false, + isSelected: member.isSelected ?? false, + isDisabled: member.isDisabled ?? false, accountID, login: member.login ?? '', - rightElement: null, icons: member.icons, pendingAction: member.pendingAction, - ...config, + reportID: member.reportID, }; } @@ -1942,7 +1934,7 @@ function formatSectionsFromSearchTerm( searchTerm: string, selectedOptions: ReportUtils.OptionData[], filteredRecentReports: ReportUtils.OptionData[], - filteredPersonalDetails: PersonalDetails[], + filteredPersonalDetails: ReportUtils.OptionData[], maxOptionsSelected: boolean, indexOffset = 0, personalDetails: OnyxEntry = {}, @@ -2025,4 +2017,4 @@ export { transformedTaxRates, }; -export type {MemberForList}; +export type {MemberForList, CategorySection}; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 0575e297da0c..90dfa8fde339 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, + ); } /** @@ -263,3 +265,5 @@ export { getPathWithoutPolicyID, getPolicyMembersByIdWithoutCurrentUser, }; + +export type {MemberEmailsToAccountIDs}; 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/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 454b85cc3152..504b2ac27965 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -601,3 +601,5 @@ export { getRecentTransactions, hasViolation, }; + +export type {TransactionChanges}; diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index 52c3ecef156c..55a6c81f0417 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -184,7 +184,7 @@ function getAvatarUrl(avatarSource: AvatarSource | undefined, accountID: number) * Avatars uploaded by users will have a _128 appended so that the asset server returns a small version. * This removes that part of the URL so the full version of the image can load. */ -function getFullSizeAvatar(avatarSource: AvatarSource, accountID: number): AvatarSource { +function getFullSizeAvatar(avatarSource: AvatarSource | undefined, accountID: number): AvatarSource { const source = getAvatar(avatarSource, accountID); if (typeof source !== 'string') { return source; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 7ee7d6c4f048..02ae638a41d3 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -5,9 +5,10 @@ import isDate from 'lodash/isDate'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; import type {OnyxCollection} from 'react-native-onyx'; +import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; import CONST from '@src/CONST'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; 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 +75,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): boolean { + if (!value) { + return false; + } if (typeof value === 'string') { return !StringUtils.isEmptyString(value); } @@ -91,15 +96,20 @@ function isRequiredFulfilled(value: string | Date | unknown[] | Record(values: FormOnyxValues, requiredFields: Array>): FormInputErrors { + const errors: FormInputErrors = {}; + requiredFields.forEach((fieldKey) => { - if (isRequiredFulfilled(values[fieldKey])) { + if (isRequiredFulfilled(values[fieldKey] as keyof FormOnyxValues)) { 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/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index c41393cb75f7..a3a3d85419e2 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -72,7 +72,9 @@ const getBrickRoadForPolicy = (report: Report): BrickRoad => { }; function hasGlobalWorkspaceSettingsRBR(policies: OnyxCollection, policyMembers: OnyxCollection) { - const cleanPolicies = Object.fromEntries(Object.entries(policies ?? {}).filter(([, policy]) => !!policy)); + // When attempting to open a policy with an invalid policyID, the policy collection is updated to include policy objects with error information. + // Only policies displayed on the policy list page should be verified. Otherwise, the user will encounter an RBR unrelated to any policies on the list. + const cleanPolicies = Object.fromEntries(Object.entries(policies ?? {}).filter(([, policy]) => policy?.id)); const cleanAllPolicyMembers = Object.fromEntries(Object.entries(policyMembers ?? {}).filter(([, policyMemberValues]) => !!policyMemberValues)); const errorCheckingMethods: CheckingMethod[] = [ @@ -143,9 +145,9 @@ function getWorkspacesBrickRoads(): Record { // The key in this map is the workspace id const workspacesBrickRoadsMap: Record = {}; - Object.values(allPolicies ?? {}).forEach((policy) => { - if (!policy) { + // Only policies which user has access to on the list should be checked. Policies that don't have an ID and contain only information about the errors aren't displayed anywhere. + if (!policy?.id) { return; } diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 2c65804cb428..c5f68317bf18 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'; @@ -22,9 +18,9 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; +import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, RequestorStepProps} from '@src/types/form/ReimbursementAccountForm'; 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 {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)); @@ -357,10 +427,11 @@ function connectBankAccountManually(bankAccountID: number, accountNumber?: strin /** * Verify the user's identity via Onfido */ -function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoData) { +function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: Record) { 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..3a0bdb94d5f5 100644 --- a/src/libs/actions/FormActions.ts +++ b/src/libs/actions/FormActions.ts @@ -1,8 +1,7 @@ import Onyx from 'react-native-onyx'; -import type {KeyValueMapping, NullishDeep} from 'react-native-onyx'; -import type {OnyxFormKeyWithoutDraft} from '@components/Form/types'; +import type {NullishDeep} from 'react-native-onyx'; import FormUtils from '@libs/FormUtils'; -import type {OnyxFormKey} from '@src/ONYXKEYS'; +import type {OnyxFormDraftKey, OnyxFormKey, OnyxValue} from '@src/ONYXKEYS'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; function setIsLoading(formID: OnyxFormKey, isLoading: boolean) { @@ -25,11 +24,11 @@ function clearErrorFields(formID: OnyxFormKey) { Onyx.merge(formID, {errorFields: null}); } -function setDraftValues(formID: OnyxFormKeyWithoutDraft, draftValues: NullishDeep) { +function setDraftValues(formID: OnyxFormKey, draftValues: NullishDeep>) { Onyx.merge(FormUtils.getDraftKey(formID), draftValues); } -function clearDraftValues(formID: OnyxFormKeyWithoutDraft) { +function clearDraftValues(formID: OnyxFormKey) { Onyx.set(FormUtils.getDraftKey(formID), null); } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 56cea2a6d4e8..7fca6614f1a1 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -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}, diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 53491b386b8c..5ae37bb85f10 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -21,7 +21,8 @@ import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {DateOfBirthForm, PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; +import type {DateOfBirthForm} from '@src/types/form'; +import type {PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; import type {SelectedTimezone, Timezone} from '@src/types/onyx/PersonalDetails'; import * as Session from './Session'; 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 { }); } +function handleExitToNavigation(exitTo: Routes | HybridAppRoute) { + InteractionManager.runAfterInteractions(() => { + waitForUserSignIn().then(() => { + Navigation.waitForProtectedRoutes().then(() => { + const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : exitTo; + Navigation.navigate(url, CONST.NAVIGATION.TYPE.FORCED_UP); + }); + }); + }); +} + +function signInWithValidateCodeAndNavigate(accountID: number, validateCode: string, twoFactorAuthCode = '', exitTo?: Routes | HybridAppRoute) { + signInWithValidateCode(accountID, validateCode, twoFactorAuthCode); + if (exitTo) { + handleExitToNavigation(exitTo); + } else { + Navigation.navigate(ROUTES.HOME); + } +} + /** * check if the route can be accessed by anonymous user * @@ -890,6 +906,7 @@ export { checkIfActionIsAllowed, signIn, signInWithValidateCode, + handleExitToNavigation, signInWithValidateCodeAndNavigate, initAutoAuthState, signInWithShortLivedAuthToken, diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 901e3698376b..28cecf460a5f 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -13,6 +13,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -309,6 +310,7 @@ function completeTask(taskReport: OnyxEntry) { 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 0a3159298c74..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'; @@ -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/libs/focusEditAfterCancelDelete/index.native.ts b/src/libs/focusEditAfterCancelDelete/index.native.ts new file mode 100755 index 000000000000..17bafabc5790 --- /dev/null +++ b/src/libs/focusEditAfterCancelDelete/index.native.ts @@ -0,0 +1,8 @@ +import {InteractionManager} from 'react-native'; +import type FocusEditAfterCancelDelete from './types'; + +const focusEditAfterCancelDelete: FocusEditAfterCancelDelete = (textInputRef) => { + InteractionManager.runAfterInteractions(() => textInputRef?.focus()); +}; + +export default focusEditAfterCancelDelete; diff --git a/src/libs/focusEditAfterCancelDelete/index.ts b/src/libs/focusEditAfterCancelDelete/index.ts new file mode 100755 index 000000000000..541c0ef1aaef --- /dev/null +++ b/src/libs/focusEditAfterCancelDelete/index.ts @@ -0,0 +1,5 @@ +import type FocusEditAfterCancelDelete from './types'; + +const focusEditAfterCancelDelete: FocusEditAfterCancelDelete = () => {}; + +export default focusEditAfterCancelDelete; diff --git a/src/libs/focusEditAfterCancelDelete/types.ts b/src/libs/focusEditAfterCancelDelete/types.ts new file mode 100755 index 000000000000..ee479203f890 --- /dev/null +++ b/src/libs/focusEditAfterCancelDelete/types.ts @@ -0,0 +1,5 @@ +import type {TextInput} from 'react-native'; + +type FocusEditAfterCancelDelete = (inputRef: TextInput | HTMLTextAreaElement | null) => void; + +export default FocusEditAfterCancelDelete; diff --git a/src/libs/onyxSubscribe.ts b/src/libs/onyxSubscribe.ts index 3a2daca900e4..afb8818ced40 100644 --- a/src/libs/onyxSubscribe.ts +++ b/src/libs/onyxSubscribe.ts @@ -8,7 +8,7 @@ import type {OnyxCollectionKey, OnyxKey} from '@src/ONYXKEYS'; * @param mapping Same as for Onyx.connect() * @return Unsubscribe callback */ -function onyxSubscribe(mapping: ConnectOptions) { +function onyxSubscribe(mapping: ConnectOptions) { const connectionId = Onyx.connect(mapping); return () => Onyx.disconnect(connectionId); } diff --git a/src/libs/searchCountryOptions.ts b/src/libs/searchCountryOptions.ts index 1fc5d343f556..953a5c81c77f 100644 --- a/src/libs/searchCountryOptions.ts +++ b/src/libs/searchCountryOptions.ts @@ -22,18 +22,54 @@ function searchCountryOptions(searchValue: string, countriesData: CountryData[]) if (!trimmedSearchValue) { return []; } - const filteredData = countriesData.filter((country) => country.searchValue.includes(trimmedSearchValue)); - return filteredData.sort((a, b) => { - if (a.value.toLowerCase() === trimmedSearchValue) { + const halfSorted = filteredData.sort((a, b) => { + // Prioritize matches at the beginning of the string + // e.g. For the search term "Bar" "Barbados" should be prioritized over Antigua & Barbuda + // The first two characters are the country code, so we start at index 2 + // and end at the length of the search term + const countryNameASubstring = a.searchValue.toLowerCase().substring(2, trimmedSearchValue.length + 2); + const countryNameBSubstring = b.searchValue.toLowerCase().substring(2, trimmedSearchValue.length + 2); + if (countryNameASubstring === trimmedSearchValue.toLowerCase()) { return -1; } - if (b.value.toLowerCase() === trimmedSearchValue) { + if (countryNameBSubstring === trimmedSearchValue.toLowerCase()) { return 1; } return 0; }); + + let fullSorted; + const unsanitizedSearchValue = searchValue.toLowerCase().trim(); + if (trimmedSearchValue !== unsanitizedSearchValue) { + // Diacritic detected, prioritize diacritic matches + // We search for diacritic matches by using the unsanitized country name and search term + fullSorted = halfSorted.sort((a, b) => { + const unsanitizedCountryNameA = a.text.toLowerCase(); + const unsanitizedCountryNameB = b.text.toLowerCase(); + if (unsanitizedCountryNameA.includes(unsanitizedSearchValue)) { + return -1; + } + if (unsanitizedCountryNameB.includes(unsanitizedSearchValue)) { + return 1; + } + return 0; + }); + } else { + // Diacritic not detected, prioritize country code matches (country codes can never contain diacritics) + // E.g. the search term 'US' should push 'United States' to the top + fullSorted = halfSorted.sort((a, b) => { + if (a.value.toLowerCase() === trimmedSearchValue) { + return -1; + } + if (b.value.toLowerCase() === trimmedSearchValue) { + return 1; + } + return 0; + }); + } + return fullSorted; } export default searchCountryOptions; diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.tsx similarity index 72% rename from src/pages/DetailsPage.js rename to src/pages/DetailsPage.tsx index a4cafd59cb73..76733fbf25e6 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.tsx @@ -1,10 +1,9 @@ +import type {StackScreenProps} from '@react-navigation/stack'; import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React from 'react'; import {ScrollView, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; import AttachmentModal from '@components/AttachmentModal'; import AutoUpdateTime from '@components/AutoUpdateTime'; import Avatar from '@components/Avatar'; @@ -18,77 +17,51 @@ import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import {parsePhoneNumber} from '@libs/PhoneNumber'; import * as ReportUtils from '@libs/ReportUtils'; import * as UserUtils from '@libs/UserUtils'; +import type {DetailsNavigatorParamList} from '@navigation/types'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import personalDetailsPropType from './personalDetailsPropType'; - -const matchType = PropTypes.shape({ - params: PropTypes.shape({ - /** login passed via route /details/:login */ - login: PropTypes.string, - - /** report ID passed */ - reportID: PropTypes.string, - }), -}); - -const propTypes = { - /* Onyx Props */ +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetails, PersonalDetailsList, Session} from '@src/types/onyx'; +type DetailsPageOnyxProps = { /** The personal details of the person who is logged in */ - personalDetails: personalDetailsPropType, - - /** Route params */ - route: matchType.isRequired, + personalDetails: OnyxEntry; /** Session info for the currently logged in user. */ - session: PropTypes.shape({ - /** Currently logged in user accountID */ - accountID: PropTypes.number, - }), - - ...withLocalizePropTypes, + session: OnyxEntry; }; -const defaultProps = { - // When opening someone else's profile (via deep link) before login, this is empty - personalDetails: {}, - session: { - accountID: 0, - }, -}; +type DetailsPageProps = DetailsPageOnyxProps & StackScreenProps; /** * Gets the phone number to display for SMS logins - * - * @param {Object} details - * @param {String} details.login - * @param {String} details.displayName - * @returns {String} */ -const getPhoneNumber = (details) => { +const getPhoneNumber = ({login = '', displayName = ''}: PersonalDetails): string | undefined => { // If the user hasn't set a displayName, it is set to their phone number, so use that - const parsedPhoneNumber = parsePhoneNumber(details.displayName); + const parsedPhoneNumber = parsePhoneNumber(displayName); if (parsedPhoneNumber.possible) { - return parsedPhoneNumber.number.e164; + return parsedPhoneNumber?.number?.e164; } // If the user has set a displayName, get the phone number from the SMS login - return details.login ? Str.removeSMSDomain(details.login) : ''; + return login ? Str.removeSMSDomain(login) : ''; }; -function DetailsPage(props) { +function DetailsPage({personalDetails, route, session}: DetailsPageProps) { const styles = useThemeStyles(); - const login = lodashGet(props.route.params, 'login', ''); - let details = _.find(props.personalDetails, (detail) => detail.login === login.toLowerCase()); + const {translate, formatPhoneNumber} = useLocalize(); + const login = route.params?.login ?? ''; + const sessionAccountID = session?.accountID ?? 0; + + let details = Object.values(personalDetails ?? {}).find((personalDetail) => personalDetail?.login === login.toLowerCase()); if (!details) { if (login === CONST.EMAIL.CONCIERGE) { @@ -116,44 +89,44 @@ function DetailsPage(props) { if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) { const localeKey = pronouns.replace(CONST.PRONOUNS.PREFIX, ''); - pronouns = props.translate(`pronouns.${localeKey}`); + pronouns = translate(`pronouns.${localeKey}` as TranslationPaths); } const phoneNumber = getPhoneNumber(details); const phoneOrEmail = isSMSLogin ? getPhoneNumber(details) : details.login; const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(details, '', false); - const isCurrentUser = props.session.accountID === details.accountID; + const isCurrentUser = sessionAccountID === details.accountID; return ( - - + + {details ? ( {({show}) => ( - + @@ -173,11 +146,11 @@ function DetailsPage(props) { style={[styles.textLabelSupporting, styles.mb1]} numberOfLines={1} > - {props.translate(isSMSLogin ? 'common.phoneNumber' : 'common.email')} + {translate(isSMSLogin ? 'common.phoneNumber' : 'common.email')} - + - {isSMSLogin ? props.formatPhoneNumber(phoneNumber) : details.login} + {isSMSLogin ? formatPhoneNumber(phoneNumber ?? '') : details.login} @@ -188,16 +161,16 @@ function DetailsPage(props) { style={[styles.textLabelSupporting, styles.mb1]} numberOfLines={1} > - {props.translate('profilePage.preferredPronouns')} + {translate('profilePage.preferredPronouns')} {pronouns} ) : null} - {shouldShowLocalTime && } + {shouldShowLocalTime && } {!isCurrentUser && ( Report.navigateToAndOpenReport([login])} @@ -213,18 +186,13 @@ function DetailsPage(props) { ); } -DetailsPage.propTypes = propTypes; -DetailsPage.defaultProps = defaultProps; DetailsPage.displayName = 'DetailsPage'; -export default compose( - withLocalize, - withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - session: { - key: ONYXKEYS.SESSION, - }, - }), -)(DetailsPage); +export default withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + session: { + key: ONYXKEYS.SESSION, + }, +})(DetailsPage); diff --git a/src/pages/EditReportFieldDatePage.tsx b/src/pages/EditReportFieldDatePage.tsx index 82659eca62c2..45d2f31073ec 100644 --- a/src/pages/EditReportFieldDatePage.tsx +++ b/src/pages/EditReportFieldDatePage.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native'; import DatePicker from '@components/DatePicker'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; -import type {OnyxFormValuesFields} from '@components/Form/types'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -11,7 +11,6 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Errors} from '@src/types/onyx/OnyxCommon'; type EditReportFieldDatePageProps = { /** Value of the policy report field */ @@ -27,7 +26,7 @@ type EditReportFieldDatePageProps = { isRequired: boolean; /** Callback to fire when the Save button is pressed */ - onSubmit: (form: OnyxFormValuesFields) => void; + onSubmit: (form: FormOnyxValues) => void; }; function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, fieldID}: EditReportFieldDatePageProps) { @@ -36,8 +35,8 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f const inputRef = useRef(null); const validate = useCallback( - (value: OnyxFormValuesFields) => { - const errors: Errors = {}; + (value: FormOnyxValues) => { + const errors: FormInputErrors = {}; if (isRequired && value[fieldID].trim() === '') { errors[fieldID] = 'common.error.fieldRequired'; } diff --git a/src/pages/EditReportFieldDropdownPage.tsx b/src/pages/EditReportFieldDropdownPage.tsx index 2815867778eb..1ad3c766221b 100644 --- a/src/pages/EditReportFieldDropdownPage.tsx +++ b/src/pages/EditReportFieldDropdownPage.tsx @@ -43,15 +43,17 @@ function EditReportFieldDropdownPage({fieldName, onSubmit, fieldID, fieldValue, const {getSafeAreaMargins} = useStyleUtils(); const {translate} = useLocalize(); const recentlyUsedOptions = useMemo(() => recentlyUsedReportFields?.[fieldID] ?? [], [recentlyUsedReportFields, fieldID]); + const [headerMessage, setHeaderMessage] = useState(''); const sections = useMemo(() => { const filteredRecentOptions = recentlyUsedOptions.filter((option) => option.toLowerCase().includes(searchValue.toLowerCase())); const filteredRestOfOptions = fieldOptions.filter((option) => !filteredRecentOptions.includes(option) && option.toLowerCase().includes(searchValue.toLowerCase())); + setHeaderMessage(!filteredRecentOptions.length && !filteredRestOfOptions.length ? translate('common.noResultsFound') : ''); return [ { title: translate('common.recents'), - shouldShow: true, + shouldShow: filteredRecentOptions.length > 0, data: filteredRecentOptions.map((option) => ({ text: option, keyForList: option, @@ -61,7 +63,7 @@ function EditReportFieldDropdownPage({fieldName, onSubmit, fieldID, fieldValue, }, { title: translate('common.all'), - shouldShow: true, + shouldShow: filteredRestOfOptions.length > 0, data: filteredRestOfOptions.map((option) => ({ text: option, keyForList: option, @@ -95,6 +97,7 @@ function EditReportFieldDropdownPage({fieldName, onSubmit, fieldID, fieldValue, onChangeText={setSearchValue} highlightSelectedOptions isRowMultilineSupported + headerMessage={headerMessage} /> )} diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx index 5bb53ef9122e..4124a9ebef98 100644 --- a/src/pages/EditReportFieldPage.tsx +++ b/src/pages/EditReportFieldPage.tsx @@ -3,7 +3,7 @@ import React from 'react'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import type {OnyxFormValuesFields} from '@components/Form/types'; +import type {FormOnyxValues} from '@components/Form/types'; import ScreenWrapper from '@components/ScreenWrapper'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; @@ -64,7 +64,7 @@ function EditReportFieldPage({route, policy, report, policyReportFields}: EditRe const isReportFieldTitle = ReportUtils.isReportFieldOfTypeTitle(reportField); - const handleReportFieldChange = (form: OnyxFormValuesFields) => { + const handleReportFieldChange = (form: FormOnyxValues) => { const value = form[reportField.fieldID] || ''; if (isReportFieldTitle) { ReportActions.updateReportName(report.reportID, value, report.reportName ?? ''); diff --git a/src/pages/EditReportFieldTextPage.tsx b/src/pages/EditReportFieldTextPage.tsx index ea9d2d3bed6d..9cda559280a9 100644 --- a/src/pages/EditReportFieldTextPage.tsx +++ b/src/pages/EditReportFieldTextPage.tsx @@ -2,7 +2,7 @@ import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; -import type {OnyxFormValuesFields} from '@components/Form/types'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -11,7 +11,6 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Errors} from '@src/types/onyx/OnyxCommon'; type EditReportFieldTextPageProps = { /** Value of the policy report field */ @@ -27,7 +26,7 @@ type EditReportFieldTextPageProps = { isRequired: boolean; /** Callback to fire when the Save button is pressed */ - onSubmit: (form: OnyxFormValuesFields) => void; + onSubmit: (form: FormOnyxValues) => void; }; function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldID}: EditReportFieldTextPageProps) { @@ -36,8 +35,8 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f const inputRef = useRef(null); const validate = useCallback( - (values: OnyxFormValuesFields) => { - const errors: Errors = {}; + (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; if (isRequired && values[fieldID].trim() === '') { errors[fieldID] = 'common.error.fieldRequired'; } diff --git a/src/pages/EditRequestMerchantPage.js b/src/pages/EditRequestMerchantPage.js index e5966bad2d2b..33c04df39e3e 100644 --- a/src/pages/EditRequestMerchantPage.js +++ b/src/pages/EditRequestMerchantPage.js @@ -11,6 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/MoneyRequestMerchantForm'; const propTypes = { /** Transaction default merchant value */ @@ -20,7 +21,11 @@ const propTypes = { onSubmit: PropTypes.func.isRequired, /** Boolean to enable validation */ - isPolicyExpenseChat: PropTypes.bool.isRequired, + isPolicyExpenseChat: PropTypes.bool, +}; + +const defaultProps = { + isPolicyExpenseChat: false, }; function EditRequestMerchantPage({defaultMerchant, onSubmit, isPolicyExpenseChat}) { @@ -59,8 +64,8 @@ function EditRequestMerchantPage({defaultMerchant, onSubmit, isPolicyExpenseChat ; - /** reportActionID of the split action */ - reportActionID: PropTypes.string, - }), - }).isRequired, + /** The report action for currently used report */ + // Used in withOnyx + // eslint-disable-next-line react/no-unused-prop-types + reportActions: OnyxEntry; /** The current transaction */ - transaction: transactionPropTypes.isRequired, + transaction: OnyxEntry; /** The draft transaction that holds data to be persisted on the current transaction */ - draftTransaction: transactionPropTypes, - - /** The report currently being used */ - report: reportPropTypes.isRequired, + draftTransaction: OnyxEntry; }; -const defaultProps = { - draftTransaction: undefined, -}; +type EditSplitBillProps = EditSplitBillOnyxProps & StackScreenProps; -function EditSplitBillPage({route, transaction, draftTransaction, report}) { - const fieldToEdit = lodashGet(route, ['params', 'field'], ''); - const reportID = lodashGet(route, ['params', 'reportID'], ''); - const reportActionID = lodashGet(route, ['params', 'reportActionID'], ''); +function EditSplitBillPage({route, transaction, draftTransaction, report}: EditSplitBillProps) { + const {field: fieldToEdit, reportID, reportActionID, currency} = route.params; const { amount: transactionAmount, @@ -59,26 +47,25 @@ function EditSplitBillPage({route, transaction, draftTransaction, report}) { merchant: transactionMerchant, category: transactionCategory, tag: transactionTag, - } = draftTransaction ? ReportUtils.getTransactionDetails(draftTransaction) : ReportUtils.getTransactionDetails(transaction); - - const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency; + } = ReportUtils.getTransactionDetails(draftTransaction ?? transaction) ?? {}; + const defaultCurrency = currency ?? transactionCurrency; function navigateBackToSplitDetails() { Navigation.navigate(ROUTES.SPLIT_BILL_DETAILS.getRoute(reportID, reportActionID)); } - const setDraftSplitTransaction = (transactionChanges) => { - IOU.setDraftSplitTransaction(transaction.transactionID, transactionChanges); + const setDraftSplitTransaction = (transactionChanges: TransactionChanges) => { + if (transaction) { + IOU.setDraftSplitTransaction(transaction.transactionID, transactionChanges); + } navigateBackToSplitDetails(); }; if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { return ( { const amount = CurrencyUtils.convertToBackendAmount(Number.parseFloat(transactionChanges.amount)); @@ -98,7 +85,7 @@ function EditSplitBillPage({route, transaction, draftTransaction, report}) { if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.MERCHANT) { return ( { setDraftSplitTransaction({merchant: transactionChanges.merchant.trim()}); }} @@ -109,8 +96,8 @@ function EditSplitBillPage({route, transaction, draftTransaction, report}) { if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.CATEGORY) { return ( { setDraftSplitTransaction({category: transactionChanges.category.trim()}); }} @@ -121,8 +108,8 @@ function EditSplitBillPage({route, transaction, draftTransaction, report}) { if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.TAG) { return ( { setDraftSplitTransaction({tag: transactionChanges.tag.trim()}); }} @@ -134,31 +121,27 @@ function EditSplitBillPage({route, transaction, draftTransaction, report}) { } EditSplitBillPage.displayName = 'EditSplitBillPage'; -EditSplitBillPage.propTypes = propTypes; -EditSplitBillPage.defaultProps = defaultProps; -export default compose( - withOnyx({ - reportActions: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.reportID}`, - canEvict: false, - }, - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, - }, - }), - // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file - withOnyx({ - transaction: { - key: ({route, reportActions}) => { - const reportAction = reportActions[`${route.params.reportActionID.toString()}`]; - return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`; - }, + +export default withOnyx({ + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, + }, + reportActions: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.reportID}`, + canEvict: false, + }, + transaction: { + key: ({route, reportActions}: Partial) => { + const reportAction = reportActions?.[`${route?.params.reportActionID.toString()}`]; + const transactionID = (reportAction as OriginalMessageIOU)?.originalMessage.IOUTransactionID ? (reportAction as OriginalMessageIOU).originalMessage.IOUTransactionID : 0; + return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; }, - draftTransaction: { - key: ({route, reportActions}) => { - const reportAction = reportActions[`${route.params.reportActionID.toString()}`]; - return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`; - }, + }, + draftTransaction: { + key: ({route, reportActions}: Partial) => { + const reportAction = reportActions?.[`${route?.params.reportActionID.toString()}`]; + const transactionID = (reportAction as OriginalMessageIOU)?.originalMessage.IOUTransactionID ? (reportAction as OriginalMessageIOU).originalMessage.IOUTransactionID : 0; + return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`; }, - }), -)(EditSplitBillPage); + }, +})(EditSplitBillPage); diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.tsx similarity index 77% rename from src/pages/NewChatPage.js rename to src/pages/NewChatPage.tsx index df95fc0a01b7..72393e89ae1a 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.tsx @@ -1,69 +1,59 @@ -import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import OfflineIndicator from '@components/OfflineIndicator'; import OptionsSelector from '@components/OptionsSelector'; import ScreenWrapper from '@components/ScreenWrapper'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import compose from '@libs/compose'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import doInteractionTask from '@libs/DoInteractionTask'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import type {OptionData} from '@libs/ReportUtils'; import variables from '@styles/variables'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import personalDetailsPropType from './personalDetailsPropType'; -import reportPropTypes from './reportPropTypes'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {DismissedReferralBanners} from '@src/types/onyx/Account'; -const propTypes = { - /** Beta features list */ - betas: PropTypes.arrayOf(PropTypes.string), +type NewChatPageWithOnyxProps = { + /** All reports shared with the user */ + reports: OnyxCollection; /** 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..7d0b8fff8e41 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx @@ -22,12 +22,14 @@ 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'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/PrivateNotesForm'; import type {PersonalDetails, Report} from '@src/types/onyx'; import type {Note} from '@src/types/onyx/Report'; @@ -36,7 +38,8 @@ type PrivateNotesEditPageOnyxProps = { personalDetailsList: OnyxCollection; }; -type PrivateNotesEditPageProps = PrivateNotesEditPageOnyxProps & +type PrivateNotesEditPageProps = WithReportAndPrivateNotesOrNotFoundProps & + PrivateNotesEditPageOnyxProps & StackScreenProps & { /** The report currently being looked at */ report: Report; @@ -141,7 +144,7 @@ function PrivateNotesEditPage({route, personalDetailsList, report}: PrivateNotes ; - - /** 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..278036430dbf 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.js +++ b/src/pages/ReimbursementAccount/BankAccountStep.js @@ -22,13 +22,14 @@ 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 INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; +import BankInfo from './BankInfo/BankInfo'; import StepPropTypes from './StepPropTypes'; const propTypes = { @@ -65,6 +66,8 @@ const defaultProps = { policyID: '', }; +const bankInfoStepKeys = INPUT_IDS.BANK_INFO_STEP; + function BankAccountStep(props) { const theme = useTheme(); const styles = useThemeStyles(); @@ -80,26 +83,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 +134,7 @@ function BankAccountStep(props) { if (props.isPlaidDisabled || !props.user.validated) { return; } + removeExistingBankAccountDetails(); BankAccounts.openPlaidView(); }} isDisabled={props.isPlaidDisabled || !props.user.validated} @@ -151,7 +150,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..a0b91ffcdb0e --- /dev/null +++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx @@ -0,0 +1,165 @@ +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 {ReimbursementAccountForm} from '@src/types/form'; +import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; +import type {ReimbursementAccount} 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 = INPUT_IDS.BANK_INFO_STEP; +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({ + // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM + 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..f2070e38b942 --- /dev/null +++ b/src/pages/ReimbursementAccount/BankInfo/substeps/Confirmation.tsx @@ -0,0 +1,113 @@ +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 {ReimbursementAccountForm} from '@src/types/form'; +import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; +import type {ReimbursementAccount} 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 = INPUT_IDS.BANK_INFO_STEP; +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 && ( + + )} +