diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index ebf43c7af42b..25b1b0dad341 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -53,10 +53,10 @@ jobs: directory: ./docs/_site - name: Setup Cloudflare CLI - run: pip3 install cloudflare + run: pip3 install cloudflare==2.17.0 - name: Purge Cloudflare cache - run: /home/runner/.local/bin/cli4 --delete hosts=["help.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache + run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["help.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache env: CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} @@ -66,7 +66,7 @@ jobs: with: token: ${{ secrets.OS_BOTIFY_TOKEN }} body: ${{ format('A preview of your ExpensifyHelp changes have been deployed to {0} ⚡️', steps.deploy.outputs.alias) }} - + - name: Reindex google search if: ${{ github.event_name == 'push' }} run : | diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 813c341caaf6..df3c2b3617bb 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -301,7 +301,7 @@ jobs: uses: ./.github/actions/composite/setupNode - name: Setup Cloudflare CLI - run: pip3 install cloudflare + run: pip3 install cloudflare==2.17.0 - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 @@ -338,13 +338,13 @@ jobs: - name: Purge production Cloudflare cache if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: /home/runner/.local/bin/cli4 --delete hosts=["new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache + run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache env: CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} - name: Purge staging Cloudflare cache if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: /home/runner/.local/bin/cli4 --delete hosts=["staging.new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache + run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["staging.new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache env: CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} 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-camera-roll/camera-roll.js b/__mocks__/@react-native-camera-roll/camera-roll.js deleted file mode 100644 index 4274cd531a85..000000000000 --- a/__mocks__/@react-native-camera-roll/camera-roll.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - CameraRoll: { - save: jest.fn(), - }, -}; diff --git a/__mocks__/@react-native-camera-roll/camera-roll.ts b/__mocks__/@react-native-camera-roll/camera-roll.ts new file mode 100644 index 000000000000..5b5084254d2e --- /dev/null +++ b/__mocks__/@react-native-camera-roll/camera-roll.ts @@ -0,0 +1,15 @@ +import type {CameraRoll} from '@react-native-camera-roll/camera-roll'; + +type CameraRollMock = { + CameraRoll: { + save: typeof CameraRoll.save; + }; +}; + +const cameraRollMock: CameraRollMock = { + CameraRoll: { + save: jest.fn(), + }, +}; + +export default cameraRollMock; 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/android/app/build.gradle b/android/app/build.gradle index 3e5f373efcd7..c41f78e5aa31 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 1001044002 - versionName "1.4.40-2" + versionCode 1001044201 + versionName "1.4.42-1" } flavorDimensions "default" diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java index c57819d19d03..3d16e607be49 100644 --- a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java +++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java @@ -98,6 +98,7 @@ protected NotificationCompat.Builder onExtendBuilder(@NonNull Context context, @ // Improve notification delivery by categorising as a time-critical message builder.setCategory(CATEGORY_MESSAGE); + builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE); // Configure the notification channel or priority to ensure it shows in foreground if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/assets/animations/Desk.lottie b/assets/animations/Desk.lottie new file mode 100644 index 000000000000..15e6caa6be0c Binary files /dev/null and b/assets/animations/Desk.lottie differ 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/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/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 8ad574d3b2e0..cc859f220608 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -97,6 +97,7 @@ 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'}, 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/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 9b1451b2bf94..a584dc723aff 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -630,8 +630,8 @@ "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipMessageCenterResources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipPreferenceCenterResources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle", "${PODS_ROOT}/../../node_modules/@expensify/react-native-live-markdown/parser/react-native-live-markdown-parser.js", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -641,8 +641,8 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipMessageCenterResources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipPreferenceCenterResources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/react-native-live-markdown-parser.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -801,8 +801,8 @@ "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipMessageCenterResources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipPreferenceCenterResources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle", "${PODS_ROOT}/../../node_modules/@expensify/react-native-live-markdown/parser/react-native-live-markdown-parser.js", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -812,8 +812,8 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipMessageCenterResources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipPreferenceCenterResources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/react-native-live-markdown-parser.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index e69495f809b7..79a54cfd702c 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.40 + 1.4.42 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.40.2 + 1.4.42.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index d1b6888f942f..d3084c0e07eb 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.40 + 1.4.42 CFBundleSignature ???? CFBundleVersion - 1.4.40.2 + 1.4.42.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 379384a63442..93e1f6e20d17 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.40 + 1.4.42 CFBundleVersion - 1.4.40.2 + 1.4.42.1 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 19a579b846e8..53bfdc978252 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1172,7 +1172,7 @@ PODS: - React-Core - react-native-image-manipulator (1.0.5): - React - - react-native-image-picker (5.1.0): + - react-native-image-picker (7.0.3): - React-Core - react-native-key-command (1.0.6): - React-Core @@ -1195,7 +1195,7 @@ PODS: - React-Core - react-native-render-html (6.3.1): - React-Core - - react-native-safe-area-context (4.7.4): + - react-native-safe-area-context (4.8.2): - React-Core - react-native-view-shot (3.8.0): - React-Core @@ -1427,7 +1427,7 @@ PODS: - Turf - RNPermissions (3.9.3): - React-Core - - RNReactNativeHapticFeedback (1.14.0): + - RNReactNativeHapticFeedback (2.2.0): - React-Core - RNReanimated (3.6.1): - glog @@ -1443,7 +1443,7 @@ PODS: - RNSound/Core (= 0.11.2) - RNSound/Core (0.11.2): - React-Core - - RNSVG (14.0.0): + - RNSVG (14.1.0): - React-Core - SDWebImage (5.17.0): - SDWebImage/Core (= 5.17.0) @@ -1458,7 +1458,7 @@ PODS: - SDWebImage/Core (~> 5.17) - SocketRocket (0.6.1) - Turf (2.7.0) - - VisionCamera (2.16.5): + - VisionCamera (2.16.8): - React - React-callinvoker - React-Core @@ -1927,7 +1927,7 @@ SPEC CHECKSUMS: react-native-document-picker: 3599b238843369026201d2ef466df53f77ae0452 react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903 react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 - react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b + react-native-image-picker: 2381c008bbb09e72395a2d043c147b11bd1523d9 react-native-key-command: 5af6ee30ff4932f78da6a2109017549042932aa5 react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: 8a7fd3f7130ef4ad2fb4276d5c9f8d3f28d2df3d @@ -1937,7 +1937,7 @@ SPEC CHECKSUMS: react-native-plaid-link-sdk: df1618a85a615d62ff34e34b76abb7a56497fbc1 react-native-quick-sqlite: bcc7a7a250a40222f18913a97cd356bf82d0a6c4 react-native-render-html: 96c979fe7452a0a41559685d2f83b12b93edac8c - react-native-safe-area-context: 2cd91d532de12acdb0a9cbc8d43ac72a8e4c897c + react-native-safe-area-context: 0ee144a6170530ccc37a0fd9388e28d06f516a89 react-native-view-shot: 6b7ed61d77d88580fed10954d45fad0eb2d47688 react-native-webview: 88293a0f23eca8465c0433c023ec632930e644d0 React-nativeconfig: d753fbbc8cecc8ae413d615599ac378bbf6999bb @@ -1978,19 +1978,19 @@ SPEC CHECKSUMS: RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: 6f638ec002aa6e906a6f766d69cd45f968d98e64 RNPermissions: 9b086c8f05b2e2faa587fdc31f4c5ab4509728aa - RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c + RNReactNativeHapticFeedback: ec56a5f81c3941206fd85625fa669ffc7b4545f9 RNReanimated: 57f436e7aa3d277fbfed05e003230b43428157c0 RNScreens: b582cb834dc4133307562e930e8fa914b8c04ef2 RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 - RNSVG: 255767813dac22db1ec2062c8b7e7b856d4e5ae6 + RNSVG: ba3e7232f45e34b7b47e74472386cf4e1a676d0a SDWebImage: 750adf017a315a280c60fde706ab1e552a3ae4e9 SDWebImageAVIFCoder: 8348fef6d0ec69e129c66c9fe4d74fbfbf366112 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: af09429398d99d524cae2fe00f6f0f6e491ed102 SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 Turf: 13d1a92d969ca0311bbc26e8356cca178ce95da2 - VisionCamera: fda554d8751e395effcc87749f8b7c198c1031be - Yoga: 13c8ef87792450193e117976337b8527b49e8c03 + VisionCamera: 0a6794d1974aed5d653d0d0cb900493e2583e35a + Yoga: e64aa65de36c0832d04e8c7bd614396c77a80047 PODFILE CHECKSUM: 0ccbb4f2406893c6e9f266dc1e7470dcd72885d2 diff --git a/package-lock.json b/package-lock.json index d499a59df097..009059d196a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.40-2", + "version": "1.4.42-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.40-2", + "version": "1.4.42-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -52,7 +52,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7aa1d42600d2f59565c1ff962f691b494ccc2813", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#a8ed0f8e1be3a1e09016e07a74cfd13c85bbc167", "expo": "^50.0.3", "expo-image": "1.10.1", "fbjs": "^3.0.2", @@ -76,7 +76,7 @@ "react-error-boundary": "^4.0.11", "react-map-gl": "^7.1.3", "react-native": "0.73.2", - "react-native-android-location-enabler": "^1.2.2", + "react-native-android-location-enabler": "^2.0.1", "react-native-blob-util": "0.19.4", "react-native-collapsible": "^1.6.1", "react-native-config": "^1.4.5", @@ -87,9 +87,9 @@ "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.14.1", "react-native-google-places-autocomplete": "2.5.6", - "react-native-haptic-feedback": "^1.13.0", + "react-native-haptic-feedback": "^2.2.0", "react-native-image-pan-zoom": "^2.1.12", - "react-native-image-picker": "^5.1.0", + "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b", "react-native-key-command": "^1.0.6", "react-native-launch-arguments": "^4.0.2", @@ -107,7 +107,7 @@ "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", @@ -31236,8 +31236,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#7aa1d42600d2f59565c1ff962f691b494ccc2813", - "integrity": "sha512-f5zeUthFwmWFiGO1as+ARuYv7TbXVktnKAjoFKhaVVSTZvgbojSnzGClCCh4uwbdl9HDRv7aiXfRN1nzuIFOTg==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#a8ed0f8e1be3a1e09016e07a74cfd13c85bbc167", + "integrity": "sha512-3d/JHWgeS+LFPRahCAXdLwnBYQk4XUYybtgCm7VsdmMDtCeGUTksLsEY7F1Zqm+ULqZjmCtYwAi8IPKy0fsSOw==", "license": "MIT", "dependencies": { "classnames": "2.4.0", @@ -44811,11 +44811,15 @@ } }, "node_modules/react-native-android-location-enabler": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/react-native-android-location-enabler/-/react-native-android-location-enabler-1.2.2.tgz", - "integrity": "sha512-CC5ghRoK3jkGNK8jdIiYIc3l0XZuQuMt2KEfldDpnMCkNz2aAfUWyLCoOniFLqtdD9poA3az+kCmUzTvLAyTiA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/react-native-android-location-enabler/-/react-native-android-location-enabler-2.0.1.tgz", + "integrity": "sha512-hDNfQL4gImrmc6K6J44kd2iKrpPHc23V4stujNIg3I1LvYLT+kwWmfTGapeY0hl6EXACfaFdm/wBb4ggtNcnPA==", + "engines": { + "node": ">= 16.0.0" + }, "peerDependencies": { - "react-native": ">= 0.60.0" + "react": ">= 18.2.0", + "react-native": ">= 0.71.0" } }, "node_modules/react-native-animatable": { @@ -45010,10 +45014,9 @@ } }, "node_modules/react-native-haptic-feedback": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/react-native-haptic-feedback/-/react-native-haptic-feedback-1.14.0.tgz", - "integrity": "sha512-dSXZ6gAzl+W/L7BPjOpnT0bx0cgQiSr0sB3DjyDJbGIdVr4ISaktZC6gC9xYFTv2kMq0+KtbKi+dpd0WtxYZMw==", - "license": "MIT", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-native-haptic-feedback/-/react-native-haptic-feedback-2.2.0.tgz", + "integrity": "sha512-3tqJOjCguWhIrX0nkURn4yw6kXdsSDjjrvZCRjKXYGlL28hdQmoW2okAHduDTD9FWj9lA+lHgwFWgGs4aFNN7A==", "peerDependencies": { "react-native": ">=0.60.0" } @@ -45029,8 +45032,9 @@ } }, "node_modules/react-native-image-picker": { - "version": "5.1.0", - "license": "MIT", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-7.0.3.tgz", + "integrity": "sha512-mFbu8AzZ8tysO7xHsQ+TmxYYJYaBCL66s4AujjgCBEAyTufm2nhjhVOw2uq1zvMDVtGoyekzCJfH3JqVmXK/3w==", "peerDependencies": { "react": "*", "react-native": "*" @@ -45329,9 +45333,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": "*" diff --git a/package.json b/package.json index 7157b28faf4b..21e9038acd96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.40-2", + "version": "1.4.42-1", "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.", @@ -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#7aa1d42600d2f59565c1ff962f691b494ccc2813", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#a8ed0f8e1be3a1e09016e07a74cfd13c85bbc167", "expo": "^50.0.3", "expo-image": "1.10.1", "fbjs": "^3.0.2", @@ -124,7 +124,7 @@ "react-error-boundary": "^4.0.11", "react-map-gl": "^7.1.3", "react-native": "0.73.2", - "react-native-android-location-enabler": "^1.2.2", + "react-native-android-location-enabler": "^2.0.1", "react-native-blob-util": "0.19.4", "react-native-collapsible": "^1.6.1", "react-native-config": "^1.4.5", @@ -135,9 +135,9 @@ "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.14.1", "react-native-google-places-autocomplete": "2.5.6", - "react-native-haptic-feedback": "^1.13.0", + "react-native-haptic-feedback": "^2.2.0", "react-native-image-pan-zoom": "^2.1.12", - "react-native-image-picker": "^5.1.0", + "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b", "react-native-key-command": "^1.0.6", "react-native-launch-arguments": "^4.0.2", @@ -155,7 +155,7 @@ "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", 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-image-picker+5.1.0.patch b/patches/react-native-image-picker+7.0.3+001+allowedMimeTypes.patch similarity index 100% rename from patches/react-native-image-picker+5.1.0.patch rename to patches/react-native-image-picker+7.0.3+001+allowedMimeTypes.patch diff --git a/src/App.tsx b/src/App.tsx index 712b82c21291..69ccbf5370cf 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'; @@ -80,6 +81,7 @@ function App({url}: AppProps) { PickerStateProvider, EnvironmentProvider, CustomStatusBarAndBackgroundContextProvider, + ActiveElementRoleProvider, ActiveWorkspaceContextProvider, ReportsContextProvider, OrderedReportIDsContextProvider, diff --git a/src/CONST.ts b/src/CONST.ts index eae4b8ec7a2b..5c99c5877559 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: { @@ -604,6 +631,7 @@ const CONST = { UPDATE_MAX_EXPENSE_AMOUNT: 'POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT', UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT: 'POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT', UPDATE_NAME: 'POLICYCHANGELOG_UPDATE_NAME', + UPDATE_DESCRIPTION: 'POLICYCHANGELOG_UPDATE_DESCRIPTION', UPDATE_OWNERSHIP: 'POLICYCHANGELOG_UPDATE_OWNERSHIP', UPDATE_REIMBURSEMENT_CHOICE: 'POLICYCHANGELOG_UPDATE_REIMBURSEMENT_CHOICE', UPDATE_REPORT_FIELD: 'POLICYCHANGELOG_UPDATE_REPORT_FIELD', @@ -623,6 +651,9 @@ const CONST = { }, THREAD_DISABLED: ['CREATED'], }, + CANCEL_PAYMENT_REASONS: { + ADMIN: 'CANCEL_REASON_ADMIN', + }, ACTIONABLE_MENTION_WHISPER_RESOLUTION: { INVITE: 'invited', NOTHING: 'nothing', @@ -1335,9 +1366,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', @@ -1556,6 +1587,15 @@ const CONST = { ]; }, + // Emails that profile view is prohibited + get RESTRICTED_EMAILS(): readonly string[] { + return [this.EMAIL.NOTIFICATIONS]; + }, + // Account IDs that profile view is prohibited + get RESTRICTED_ACCOUNT_IDS() { + return [this.ACCOUNT_ID.NOTIFICATIONS]; + }, + // Auth limit is 60k for the column but we store edits and other metadata along the html so let's use a lower limit to accommodate for it. MAX_COMMENT_LENGTH: 10000, @@ -3194,6 +3234,42 @@ const CONST = { }, REPORT_FIELD_TITLE_FIELD_ID: 'text_title', + + DEBUG_CONSOLE: { + LEVELS: { + INFO: 'INFO', + ERROR: 'ERROR', + RESULT: 'RESULT', + DEBUG: 'DEBUG', + }, + }, + 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 4a9c809485e5..5755296f3bb5 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'; /** @@ -141,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', @@ -236,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', @@ -260,6 +260,12 @@ const ONYXKEYS = { /** Indicates whether an forced upgrade is required */ UPDATE_REQUIRED: 'updateRequired', + /** Stores the logs of the app for debugging purposes */ + LOGS: 'logs', + + /** Indicates whether we should store logs or not */ + SHOULD_STORE_LOGS: 'shouldStoreLogs', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -311,6 +317,8 @@ const ONYXKEYS = { ADD_DEBIT_CARD_FORM_DRAFT: 'addDebitCardFormDraft', WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', WORKSPACE_SETTINGS_FORM_DRAFT: 'workspaceSettingsFormDraft', + WORKSPACE_DESCRIPTION_FORM: 'workspaceDescriptionForm', + WORKSPACE_DESCRIPTION_FORM_DRAFT: 'workspaceDescriptionFormDraft', WORKSPACE_RATE_AND_UNIT_FORM: 'workspaceRateAndUnitForm', WORKSPACE_RATE_AND_UNIT_FORM_DRAFT: 'workspaceRateAndUnitFormDraft', CLOSE_ACCOUNT_FORM: 'closeAccount', @@ -378,12 +386,86 @@ 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; + [ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceDescriptionForm; +}; -type OnyxValues = { +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 OnyxValuesMapping = { [ONYXKEYS.ACCOUNT]: OnyxTypes.Account; [ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string; [ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER]: boolean; @@ -426,6 +508,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; @@ -463,113 +546,25 @@ type OnyxValues = { [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.UPDATE_REQUIRED]: boolean; + [ONYXKEYS.PLAID_CURRENT_EVENT]: string; + [ONYXKEYS.LOGS]: Record; + [ONYXKEYS.SHOULD_STORE_LOGS]: boolean; +}; - // 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 082cea49d771..4eaff69a970a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -153,6 +153,12 @@ const ROUTES = { SETTINGS_STATUS_CLEAR_AFTER: 'settings/profile/status/clear-after', SETTINGS_STATUS_CLEAR_AFTER_DATE: 'settings/profile/status/clear-after/date', SETTINGS_STATUS_CLEAR_AFTER_TIME: 'settings/profile/status/clear-after/time', + SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', + SETTINGS_CONSOLE: 'settings/troubleshoot/console', + SETTINGS_SHARE_LOG: { + route: 'settings/troubleshoot/console/share-log', + getRoute: (source: string) => `settings/troubleshoot/console/share-log?source=${encodeURI(source)}` as const, + }, KEYBOARD_SHORTCUTS: 'keyboard-shortcuts', @@ -195,7 +201,7 @@ const ROUTES = { }, REPORT_WITH_ID_DETAILS: { route: 'r/:reportID/details', - getRoute: (reportID: string) => `r/${reportID}/details` as const, + getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/details`, backTo), }, REPORT_SETTINGS: { route: 'r/:reportID/settings', @@ -348,9 +354,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/distance/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_MERCHANT: { - route: 'create/:iouType/merchant/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/merchant/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/merchant/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/merchant/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_PARTICIPANTS: { route: 'create/:iouType/participants/:transactionID/:reportID', @@ -444,6 +450,10 @@ const ROUTES = { route: 'workspace/:policyID/profile/name', getRoute: (policyID: string) => `workspace/${policyID}/profile/name` as const, }, + WORKSPACE_DESCRIPTION: { + route: 'workspace/:policyID/description', + getRoute: (policyID: string) => `workspace/${policyID}/description` as const, + }, WORKSPACE_AVATAR: { route: 'workspace/:policyID/avatar', getRoute: (policyID: string) => `workspace/${policyID}/avatar` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1626fdbd1898..93692ec1f917 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -35,6 +35,9 @@ const SCREENS = { CLOSE: 'Settings_Close', TWO_FACTOR_AUTH: 'Settings_TwoFactorAuth', REPORT_CARD_LOST_OR_DAMAGED: 'Settings_ReportCardLostOrDamaged', + TROUBLESHOOT: 'Settings_Troubleshoot', + CONSOLE: 'Settings_Console', + SHARE_LOG: 'Share_Log', PROFILE: { ROOT: 'Settings_Profile', @@ -147,7 +150,6 @@ const SCREENS = { CONFIRMATION: 'Money_Request_Confirmation', CURRENCY: 'Money_Request_Currency', CATEGORY: 'Money_Request_Category', - MERCHANT: 'Money_Request_Merchant', WAYPOINT: 'Money_Request_Waypoint', EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint', DISTANCE: 'Money_Request_Distance', @@ -207,6 +209,7 @@ const SCREENS = { INVITE_MESSAGE: 'Workspace_Invite_Message', CURRENCY: 'Workspace_Profile_Currency', NAME: 'Workspace_Profile_Name', + DESCRIPTION: 'Workspace_Description', }, EDIT_REQUEST: { 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; + + /** Whether the currency symbol is pressable */ + isCurrencyPressable?: boolean; }; /** @@ -47,13 +51,13 @@ 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, + {value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, errorText, onInputChange, onCurrencyButtonPress, isCurrencyPressable = true}: AmountFormProps, + forwardedRef: ForwardedRef, ) { const styles = useThemeStyles(); const {toLocaleDigit, numberFormat} = useLocalize(); - const textInput = useRef(null); + const textInput = useRef(null); const decimals = CurrencyUtils.getCurrencyDecimals(currency) + extraDecimals; const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); @@ -187,12 +191,11 @@ function AmountForm( style={[styles.moneyRequestAmountContainer, styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]} > { + ref={(ref: BaseTextInputRef) => { if (typeof forwardedRef === 'function') { forwardedRef(ref); } else if (forwardedRef && 'current' in forwardedRef) { @@ -210,10 +213,11 @@ function AmountForm( setSelection(e.nativeEvent.selection); }} onKeyPress={textInputKeyPress} + isCurrencyPressable={isCurrencyPressable} /> {!!errorText && ( diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 05080fcdd21c..fc7fddc094f5 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type {ForwardedRef} from 'react'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle, ViewStyle} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {TextSelection} from './Composer/types'; @@ -21,7 +21,7 @@ type AmountTextInputProps = { selection?: TextSelection; /** Function to call when selection in text input is changed */ - onSelectionChange?: () => void; + onSelectionChange?: (event: NativeSyntheticEvent) => void; /** Style for the input */ style?: StyleProp; @@ -30,7 +30,7 @@ type AmountTextInputProps = { touchableInputWrapperStyle?: StyleProp; /** Function to call to handle key presses in the text input */ - onKeyPress?: () => void; + onKeyPress?: (event: NativeSyntheticEvent) => void; }; function AmountTextInput( @@ -54,7 +54,7 @@ function AmountTextInput( selection={selection} onSelectionChange={onSelectionChange} role={CONST.ROLE.PRESENTATION} - onKeyPress={onKeyPress} + onKeyPress={onKeyPress as (event: NativeSyntheticEvent) => void} touchableInputWrapperStyle={touchableInputWrapperStyle} /> ); 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/Button/index.tsx b/src/components/Button/index.tsx index f4b6e8b23ecf..1777b239e714 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -265,14 +265,16 @@ function Button( return ( <> - + {pressOnEnter && ( + + )} { @@ -318,7 +320,7 @@ function Button( shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'text' in rest && (rest?.icon || rest?.shouldShowRightIcon) ? styles.alignItemsStretch : undefined, + 'text' in rest && rest?.shouldShowRightIcon ? styles.alignItemsStretch : undefined, innerStyles, ]} hoverStyle={[ diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx index c38241b275ba..aaa43e33744d 100644 --- a/src/components/ConfirmedRoute.tsx +++ b/src/components/ConfirmedRoute.tsx @@ -15,14 +15,9 @@ import type IconAsset from '@src/types/utils/IconAsset'; import DistanceMapView from './DistanceMapView'; import * as Expensicons from './Icon/Expensicons'; import ImageSVG from './ImageSVG'; +import type {WayPoint} from './MapView/MapViewTypes'; import PendingMapView from './MapView/PendingMapView'; -type WayPoint = { - id: string; - coordinate: [number, number]; - markerComponent: () => ReactNode; -}; - type ConfirmedRoutePropsOnyxProps = { /** Data about Mapbox token for calling Mapbox API */ mapboxAccessToken: OnyxEntry; diff --git a/src/components/CurrencySymbolButton.tsx b/src/components/CurrencySymbolButton.tsx index e913b415c328..2b736c2ef527 100644 --- a/src/components/CurrencySymbolButton.tsx +++ b/src/components/CurrencySymbolButton.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,13 +16,16 @@ type CurrencySymbolButtonProps = { /** Function to call when currency button is pressed */ onCurrencyButtonPress: () => void; + + /** Whether the currency button is pressable or not */ + isCurrencyPressable?: boolean; }; -function CurrencySymbolButton({onCurrencyButtonPress, currencySymbol}: CurrencySymbolButtonProps) { +function CurrencySymbolButton({onCurrencyButtonPress, currencySymbol, isCurrencyPressable = true}: CurrencySymbolButtonProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); - return ( + return isCurrencyPressable ? ( {currencySymbol} + ) : ( + + {currencySymbol} + ); } 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/CalendarPicker/ArrowIcon.js b/src/components/DatePicker/CalendarPicker/ArrowIcon.tsx similarity index 61% rename from src/components/DatePicker/CalendarPicker/ArrowIcon.js rename to src/components/DatePicker/CalendarPicker/ArrowIcon.tsx index 11455cb4f78a..5c58ed22f6fa 100644 --- a/src/components/DatePicker/CalendarPicker/ArrowIcon.js +++ b/src/components/DatePicker/CalendarPicker/ArrowIcon.tsx @@ -1,6 +1,6 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -8,25 +8,20 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -const propTypes = { +type ArrowIconProps = { /** Specifies if the arrow icon should be disabled or not. */ - disabled: PropTypes.bool, + disabled?: boolean; /** Specifies direction of icon */ - direction: PropTypes.oneOf([CONST.DIRECTION.LEFT, CONST.DIRECTION.RIGHT]), + direction?: ValueOf; }; -const defaultProps = { - disabled: false, - direction: CONST.DIRECTION.RIGHT, -}; - -function ArrowIcon(props) { +function ArrowIcon({disabled = false, direction = CONST.DIRECTION.RIGHT}: ArrowIconProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); return ( - + void; /** Function to call when the user closes the year picker */ - onClose: PropTypes.func, + onClose?: () => void; }; -const defaultProps = { - currentYear: new Date().getFullYear(), - onYearChange: () => {}, - onClose: () => {}, -}; - -function YearPickerModal(props) { +function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear(), onYearChange, onClose}: YearPickerModalProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [searchText, setSearchText] = useState(''); const {sections, headerMessage} = useMemo(() => { - const yearsList = searchText === '' ? props.years : _.filter(props.years, (year) => year.text.includes(searchText)); + const yearsList = searchText === '' ? years : years.filter((year) => year.text.includes(searchText)); return { headerMessage: !yearsList.length ? translate('common.noResultsFound') : '', sections: [{data: yearsList, indexOffset: 0}], }; - }, [props.years, searchText, translate]); + }, [years, searchText, translate]); useEffect(() => { - if (props.isVisible) { + if (isVisible) { return; } setSearchText(''); - }, [props.isVisible]); + }, [isVisible]); return ( onClose?.()} + onModalHide={onClose} hideModalContentWhileAnimating useNativeDriver > @@ -69,7 +61,7 @@ function YearPickerModal(props) { > props.onYearChange(option.value)} - initiallyFocusedOptionKey={props.currentYear.toString()} + onSelectRow={(option: CalendarPickerRadioItem) => { + onYearChange?.(option.value); + }} + initiallyFocusedOptionKey={currentYear.toString()} showScrollIndicator shouldStopPropagation /> @@ -90,8 +84,6 @@ function YearPickerModal(props) { ); } -YearPickerModal.propTypes = propTypes; -YearPickerModal.defaultProps = defaultProps; YearPickerModal.displayName = 'YearPickerModal'; export default YearPickerModal; diff --git a/src/components/DatePicker/CalendarPicker/generateMonthMatrix.js b/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts similarity index 71% rename from src/components/DatePicker/CalendarPicker/generateMonthMatrix.js rename to src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts index ecf338d36424..21e7ff752794 100644 --- a/src/components/DatePicker/CalendarPicker/generateMonthMatrix.js +++ b/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts @@ -4,20 +4,14 @@ import DateUtils from '@libs/DateUtils'; /** * Generates a matrix representation of a month's calendar given the year and month. * - * @param {Number} year - The year for which to generate the month matrix. - * @param {Number} month - The month (0-indexed) for which to generate the month matrix. - * @returns {Array>} - A 2D array of the month's calendar days, with null values representing days outside the current month. + * @param year - The year for which to generate the month matrix. + * @param month - The month (0-indexed) for which to generate the month matrix. + * @returns A 2D array of the month's calendar days, with null values representing days outside the current month. */ -export default function generateMonthMatrix(year, month) { - if (typeof year !== 'number') { - throw new TypeError('Year must be a number'); - } +export default function generateMonthMatrix(year: number, month: number) { if (year < 0) { throw new Error('Year cannot be less than 0'); } - if (typeof month !== 'number') { - throw new TypeError('Month must be a number'); - } if (month < 0) { throw new Error('Month cannot be less than 0'); } @@ -51,14 +45,14 @@ export default function generateMonthMatrix(year, month) { // Add null values for days after the last day of the month if (currentWeek.length > 0) { for (let i = currentWeek.length; i < 7; i++) { - currentWeek.push(null); + currentWeek.push(undefined); } matrix.push(currentWeek); } // Add null values for days before the first day of the month for (let i = matrix[0].length; i < 7; i++) { - matrix[0].unshift(null); + matrix[0].unshift(undefined); } return matrix; diff --git a/src/components/DatePicker/CalendarPicker/index.js b/src/components/DatePicker/CalendarPicker/index.js deleted file mode 100644 index f10af5e4a5a7..000000000000 --- a/src/components/DatePicker/CalendarPicker/index.js +++ /dev/null @@ -1,296 +0,0 @@ -import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns'; -import Str from 'expensify-common/lib/str'; -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; -import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withStyleUtils, {withStyleUtilsPropTypes} from '@components/withStyleUtils'; -import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; -import compose from '@libs/compose'; -import DateUtils from '@libs/DateUtils'; -import getButtonState from '@libs/getButtonState'; -import CONST from '@src/CONST'; -import ArrowIcon from './ArrowIcon'; -import generateMonthMatrix from './generateMonthMatrix'; -import YearPickerModal from './YearPickerModal'; - -const propTypes = { - /** An initial value of date string */ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), - - /** A minimum date (oldest) allowed to select */ - minDate: PropTypes.instanceOf(Date), - - /** A maximum date (earliest) allowed to select */ - maxDate: PropTypes.instanceOf(Date), - - /** A function called when the date is selected */ - onSelected: PropTypes.func, - - ...withLocalizePropTypes, - ...withThemeStylesPropTypes, - ...withStyleUtilsPropTypes, -}; - -const defaultProps = { - value: new Date(), - minDate: setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR), - maxDate: setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR), - onSelected: () => {}, -}; - -class CalendarPicker extends React.PureComponent { - constructor(props) { - super(props); - - if (props.minDate >= props.maxDate) { - throw new Error('Minimum date cannot be greater than the maximum date.'); - } - let currentDateView = typeof props.value === 'string' ? parseISO(props.value) : new Date(props.value); - if (props.maxDate < currentDateView) { - currentDateView = props.maxDate; - } else if (props.minDate > currentDateView) { - currentDateView = props.minDate; - } - - const minYear = getYear(new Date(this.props.minDate)); - const maxYear = getYear(new Date(this.props.maxDate)); - - this.state = { - currentDateView, - isYearPickerVisible: false, - years: _.map( - Array.from({length: maxYear - minYear + 1}, (v, i) => i + minYear), - (value) => ({ - text: value.toString(), - value, - keyForList: value.toString(), - isSelected: value === currentDateView.getFullYear(), - }), - ), - }; - - this.moveToPrevMonth = this.moveToPrevMonth.bind(this); - this.moveToNextMonth = this.moveToNextMonth.bind(this); - this.onDayPressed = this.onDayPressed.bind(this); - this.onYearSelected = this.onYearSelected.bind(this); - } - - onYearSelected(year) { - this.setState((prev) => { - const newCurrentDateView = setYear(new Date(prev.currentDateView), year); - - return { - currentDateView: newCurrentDateView, - isYearPickerVisible: false, - years: _.map(prev.years, (item) => ({ - ...item, - isSelected: item.value === newCurrentDateView.getFullYear(), - })), - }; - }); - } - - /** - * Calls the onSelected function with the selected date. - * @param {Number} day - The day of the month that was selected. - */ - onDayPressed(day) { - this.setState( - (prev) => ({ - currentDateView: setDate(new Date(prev.currentDateView), day), - }), - () => this.props.onSelected(format(new Date(this.state.currentDateView), CONST.DATE.FNS_FORMAT_STRING)), - ); - } - - /** - * Handles the user pressing the previous month arrow of the calendar picker. - */ - moveToPrevMonth() { - this.setState((prev) => { - const prevMonth = subMonths(new Date(prev.currentDateView), 1); - // if year is subtracted, we need to update the years list - let newYears = prev.years; - if (prevMonth.getFullYear() < prev.currentDateView.getFullYear()) { - newYears = _.map(prev.years, (item) => ({ - ...item, - isSelected: item.value === prevMonth.getFullYear(), - })); - } - - return { - currentDateView: prevMonth, - years: newYears, - }; - }); - } - - /** - * Handles the user pressing the next month arrow of the calendar picker. - */ - moveToNextMonth() { - this.setState((prev) => { - const nextMonth = addMonths(new Date(prev.currentDateView), 1); - // if year is added, we need to update the years list - let newYears = prev.years; - if (nextMonth.getFullYear() > prev.currentDateView.getFullYear()) { - newYears = _.map(prev.years, (item) => ({ - ...item, - isSelected: item.value === nextMonth.getFullYear(), - })); - } - - return { - currentDateView: nextMonth, - years: newYears, - }; - }); - } - - render() { - const monthNames = _.map(DateUtils.getMonthNames(this.props.preferredLocale), Str.recapitalize); - const daysOfWeek = _.map(DateUtils.getDaysOfWeek(this.props.preferredLocale), (day) => day.toUpperCase()); - const currentMonthView = this.state.currentDateView.getMonth(); - const currentYearView = this.state.currentDateView.getFullYear(); - const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView); - const hasAvailableDatesNextMonth = startOfDay(new Date(this.props.maxDate)) > endOfMonth(new Date(this.state.currentDateView)); - const hasAvailableDatesPrevMonth = endOfDay(new Date(this.props.minDate)) < startOfMonth(new Date(this.state.currentDateView)); - - return ( - - - this.setState({isYearPickerVisible: true})} - style={[this.props.themeStyles.alignItemsCenter, this.props.themeStyles.flexRow, this.props.themeStyles.flex1, this.props.themeStyles.justifyContentStart]} - wrapperStyle={[this.props.themeStyles.alignItemsCenter]} - hoverDimmingValue={1} - testID="currentYearButton" - accessibilityLabel={this.props.translate('common.currentYear')} - > - - {currentYearView} - - - - - - {monthNames[currentMonthView]} - - - - - - - - - - - {_.map(daysOfWeek, (dayOfWeek) => ( - - {dayOfWeek[0]} - - ))} - - {_.map(calendarDaysMatrix, (week) => ( - - {_.map(week, (day, index) => { - const currentDate = new Date(currentYearView, currentMonthView, day); - const isBeforeMinDate = currentDate < startOfDay(new Date(this.props.minDate)); - const isAfterMaxDate = currentDate > startOfDay(new Date(this.props.maxDate)); - const isDisabled = !day || isBeforeMinDate || isAfterMaxDate; - const isSelected = !!day && isSameDay(parseISO(this.props.value), new Date(currentYearView, currentMonthView, day)); - return ( - this.onDayPressed(day)} - style={this.props.themeStyles.calendarDayRoot} - accessibilityLabel={day ? day.toString() : undefined} - tabIndex={day ? 0 : -1} - accessible={Boolean(day)} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} - > - {({hovered, pressed}) => ( - - {day} - - )} - - ); - })} - - ))} - this.setState({isYearPickerVisible: false})} - /> - - ); - } -} - -CalendarPicker.propTypes = propTypes; -CalendarPicker.defaultProps = defaultProps; - -export default compose(withLocalize, withThemeStyles, withStyleUtils)(CalendarPicker); diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx new file mode 100644 index 000000000000..60673f9a28d2 --- /dev/null +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -0,0 +1,268 @@ +import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns'; +import Str from 'expensify-common/lib/str'; +import React, {useState} from 'react'; +import {View} from 'react-native'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import getButtonState from '@libs/getButtonState'; +import CONST from '@src/CONST'; +import ArrowIcon from './ArrowIcon'; +import generateMonthMatrix from './generateMonthMatrix'; +import type CalendarPickerRadioItem from './types'; +import YearPickerModal from './YearPickerModal'; + +type CalendarPickerProps = { + /** An initial value of date string */ + value?: Date | string; + + /** A minimum date (oldest) allowed to select */ + minDate?: Date; + + /** A maximum date (earliest) allowed to select */ + maxDate?: Date; + + /** A function called when the date is selected */ + onSelected?: (selectedDate: string) => void; +}; + +function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { + let initialCurrentDateView = typeof value === 'string' ? parseISO(value) : new Date(value); + + if (maxDate < initialCurrentDateView) { + initialCurrentDateView = maxDate; + } else if (minDate > initialCurrentDateView) { + initialCurrentDateView = minDate; + } + + return initialCurrentDateView; +} + +function CalendarPicker({ + value = new Date(), + minDate = setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR), + maxDate = setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR), + onSelected, +}: CalendarPickerProps) { + const themeStyles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {preferredLocale, translate} = useLocalize(); + + const [currentDateView, setCurrentDateView] = useState(getInitialCurrentDateView(value, minDate, maxDate)); + + const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); + + const minYear = getYear(new Date(minDate)); + const maxYear = getYear(new Date(maxDate)); + + const [years, setYears] = useState( + Array.from({length: maxYear - minYear + 1}, (v, i) => i + minYear).map((year) => ({ + text: year.toString(), + value: year, + keyForList: year.toString(), + isSelected: year === currentDateView.getFullYear(), + })), + ); + + const onYearSelected = (year: number) => { + setIsYearPickerVisible(false); + setCurrentDateView((prev) => { + const newCurrentDateView = setYear(new Date(prev), year); + setYears((prevYears) => + prevYears.map((item) => ({ + ...item, + isSelected: item.value === newCurrentDateView.getFullYear(), + })), + ); + return newCurrentDateView; + }); + }; + + /** + * Calls the onSelected function with the selected date. + * @param day - The day of the month that was selected. + */ + const onDayPressed = (day: number) => { + setCurrentDateView((prev) => { + const newCurrentDateView = setDate(new Date(prev), day); + onSelected?.(format(new Date(newCurrentDateView), CONST.DATE.FNS_FORMAT_STRING)); + return newCurrentDateView; + }); + }; + + /** + * Handles the user pressing the previous month arrow of the calendar picker. + */ + const moveToPrevMonth = () => { + setCurrentDateView((prev) => { + const prevMonth = subMonths(new Date(prev), 1); + // if year is subtracted, we need to update the years list + if (prevMonth.getFullYear() < prev.getFullYear()) { + setYears((prevYears) => + prevYears.map((item) => ({ + ...item, + isSelected: item.value === prevMonth.getFullYear(), + })), + ); + } + return prevMonth; + }); + }; + + /** + * Handles the user pressing the next month arrow of the calendar picker. + */ + const moveToNextMonth = () => { + setCurrentDateView((prev) => { + const nextMonth = addMonths(new Date(prev), 1); + // if year is added, we need to update the years list + if (nextMonth.getFullYear() > prev.getFullYear()) { + setYears((prevYears) => + prevYears.map((item) => ({ + ...item, + isSelected: item.value === nextMonth.getFullYear(), + })), + ); + } + + return nextMonth; + }); + }; + + const monthNames = DateUtils.getMonthNames(preferredLocale).map((month) => Str.recapitalize(month)); + const daysOfWeek = DateUtils.getDaysOfWeek(preferredLocale).map((day) => day.toUpperCase()); + const currentMonthView = currentDateView.getMonth(); + const currentYearView = currentDateView.getFullYear(); + const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView); + const hasAvailableDatesNextMonth = startOfDay(new Date(maxDate)) > endOfMonth(new Date(currentDateView)); + const hasAvailableDatesPrevMonth = endOfDay(new Date(minDate)) < startOfMonth(new Date(currentDateView)); + + return ( + + + setIsYearPickerVisible(true)} + style={[themeStyles.alignItemsCenter, themeStyles.flexRow, themeStyles.flex1, themeStyles.justifyContentStart]} + wrapperStyle={[themeStyles.alignItemsCenter]} + hoverDimmingValue={1} + testID="currentYearButton" + accessibilityLabel={translate('common.currentYear')} + > + + {currentYearView} + + + + + + {monthNames[currentMonthView]} + + + + + + + + + + + {daysOfWeek.map((dayOfWeek) => ( + + {dayOfWeek[0]} + + ))} + + {calendarDaysMatrix.map((week) => ( + + {week.map((day, index) => { + const currentDate = new Date(currentYearView, currentMonthView, day); + const isBeforeMinDate = currentDate < startOfDay(new Date(minDate)); + const isAfterMaxDate = currentDate > startOfDay(new Date(maxDate)); + const isDisabled = !day || isBeforeMinDate || isAfterMaxDate; + const isSelected = !!day && isSameDay(parseISO(value.toString()), new Date(currentYearView, currentMonthView, day)); + const handleOnPress = () => { + if (!day) { + return; + } + + onDayPressed(day); + }; + const key = `${index}_day-${day}`; + return ( + + {({hovered, pressed}) => ( + + {day} + + )} + + ); + })} + + ))} + setIsYearPickerVisible(false)} + /> + + ); +} + +export default CalendarPicker; diff --git a/src/components/DatePicker/CalendarPicker/types.ts b/src/components/DatePicker/CalendarPicker/types.ts new file mode 100644 index 000000000000..ce5e16d50535 --- /dev/null +++ b/src/components/DatePicker/CalendarPicker/types.ts @@ -0,0 +1,8 @@ +import type {RadioItem} from '@components/SelectionList/types'; + +type CalendarPickerRadioItem = RadioItem & { + /** The value representing a year in the CalendarPicker */ + value: number; +}; + +export default CalendarPickerRadioItem; diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.tsx similarity index 53% rename from src/components/DatePicker/index.js rename to src/components/DatePicker/index.tsx index a2ca930690ac..b7c871d4a938 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.tsx @@ -1,77 +1,101 @@ import {setYear} from 'date-fns'; -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, {forwardRef, useState} from 'react'; +import type {ForwardedRef} 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'; import TextInput from '@components/TextInput'; -import {propTypes as baseTextInputPropTypes, defaultProps as defaultBaseTextInputPropTypes} from '@components/TextInput/BaseTextInput/baseTextInputPropTypes'; +import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; import CalendarPicker from './CalendarPicker'; -const propTypes = { - /** React ref being forwarded to the DatePicker input */ - forwardedRef: refPropTypes, - +type DatePickerProps = { /** * The datepicker supports any value that `new Date()` can parse. * `onInputChange` would always be called with a Date (or null) */ - value: PropTypes.string, + value?: string; /** * The datepicker supports any defaultValue that `new Date()` can parse. * `onInputChange` would always be called with a Date (or null) */ - defaultValue: PropTypes.string, + defaultValue?: string; - inputID: PropTypes.string.isRequired, + inputID: string; /** A minimum date of calendar to select */ - minDate: PropTypes.objectOf(Date), + minDate?: Date; /** A maximum date of calendar to select */ - maxDate: PropTypes.objectOf(Date), + maxDate?: Date; /** A function that is passed by FormWrapper */ - onInputChange: PropTypes.func.isRequired, + onInputChange: (value: Date) => void; /** A function that is passed by FormWrapper */ - onTouched: PropTypes.func.isRequired, - - ...baseTextInputPropTypes, -}; - -const datePickerDefaultProps = { - ...defaultBaseTextInputPropTypes, - minDate: setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR), - maxDate: setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR), - value: undefined, -}; - -function DatePicker({forwardedRef, containerStyles, defaultValue, disabled, errorText, inputID, isSmallScreenWidth, label, maxDate, minDate, onInputChange, onTouched, placeholder, value}) { + onTouched: () => void; + + /** Saves a draft of the input value when used in a form */ + shouldSaveDraft?: boolean; + + /** ID of the wrapping form */ + formID?: keyof OnyxFormValuesMapping; +} & BaseTextInputProps; + +function DatePicker( + { + containerStyles, + defaultValue, + disabled, + errorText, + inputID, + label, + maxDate = setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR), + minDate = setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR), + onInputChange, + onTouched, + placeholder, + value, + shouldSaveDraft = false, + formID, + }: DatePickerProps, + ref: ForwardedRef, +) { const styles = useThemeStyles(); const {translate} = useLocalize(); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [selectedDate, setSelectedDate] = useState(value || defaultValue || undefined); + const {isSmallScreenWidth} = useWindowDimensions(); - const onSelected = (newValue) => { - if (_.isFunction(onTouched)) { - onTouched(); - } - if (_.isFunction(onInputChange)) { - onInputChange(newValue); - } + const onSelected = (newValue: string) => { + onTouched?.(); + onInputChange?.(newValue); 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 || !value) { + return; + } + + setSelectedDate(value); + }, [formID, inputID, selectedDate, shouldSaveDraft, value]); + return ( ( - -)); - -DatePickerWithRef.displayName = 'DatePickerWithRef'; - -export default DatePickerWithRef; +export default forwardRef(DatePicker); 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/DistanceRequestFooter.js b/src/components/DistanceRequest/DistanceRequestFooter.tsx similarity index 65% rename from src/components/DistanceRequest/DistanceRequestFooter.js rename to src/components/DistanceRequest/DistanceRequestFooter.tsx index cb164917cd57..357074478bd8 100644 --- a/src/components/DistanceRequest/DistanceRequestFooter.js +++ b/src/components/DistanceRequest/DistanceRequestFooter.tsx @@ -1,77 +1,61 @@ -import lodashGet from 'lodash/get'; -import lodashIsNil from 'lodash/isNil'; -import PropTypes from 'prop-types'; import React, {useMemo} from 'react'; +import type {ReactNode} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import DistanceMapView from '@components/DistanceMapView'; import * as Expensicons from '@components/Icon/Expensicons'; import ImageSVG from '@components/ImageSVG'; -import transactionPropTypes from '@components/transactionPropTypes'; +import type {WayPoint} from '@components/MapView/MapViewTypes'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {MapboxAccessToken} from '@src/types/onyx'; +import type {WaypointCollection} from '@src/types/onyx/Transaction'; +import type Transaction from '@src/types/onyx/Transaction'; +import type IconAsset from '@src/types/utils/IconAsset'; const MAX_WAYPOINTS = 25; -const propTypes = { +type DistanceRequestFooterOnyxProps = { + /** Data about Mapbox token for calling Mapbox API */ + mapboxAccessToken: OnyxEntry; +}; + +type DistanceRequestFooterProps = DistanceRequestFooterOnyxProps & { /** The waypoints for the distance request */ - waypoints: PropTypes.objectOf( - PropTypes.shape({ - lat: PropTypes.number, - lng: PropTypes.number, - address: PropTypes.string, - name: PropTypes.string, - }), - ), + waypoints?: WaypointCollection; /** Function to call when the user wants to add a new waypoint */ - navigateToWaypointEditPage: PropTypes.func.isRequired, - - /** Data about Mapbox token for calling Mapbox API */ - mapboxAccessToken: PropTypes.shape({ - /** Temporary token for Mapbox API */ - token: PropTypes.string, - - /** Time when the token will expire in ISO 8601 */ - expiration: PropTypes.string, - }), + navigateToWaypointEditPage: (index: number) => void; /** The transaction being interacted with */ - transaction: transactionPropTypes, + transaction: OnyxEntry; }; -const defaultProps = { - waypoints: {}, - mapboxAccessToken: { - token: '', - }, - transaction: {}, -}; -function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navigateToWaypointEditPage}) { +function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navigateToWaypointEditPage}: DistanceRequestFooterProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const numberOfWaypoints = _.size(waypoints); - const numberOfFilledWaypoints = _.size(_.filter(waypoints, (waypoint) => !_.isEmpty(waypoint))); + const numberOfWaypoints = Object.keys(waypoints ?? {}).length; + const numberOfFilledWaypoints = Object.values(waypoints ?? {}).filter((waypoint) => Object.keys(waypoint).length).length; const lastWaypointIndex = numberOfWaypoints - 1; const waypointMarkers = useMemo( () => - _.filter( - _.map(waypoints, (waypoint, key) => { - if (!waypoint || lodashIsNil(waypoint.lat) || lodashIsNil(waypoint.lng)) { + Object.entries(waypoints ?? {}) + .map(([key, waypoint]) => { + if (!waypoint?.lat || !waypoint?.lng) { return; } const index = TransactionUtils.getWaypointIndex(key); - let MarkerComponent; + let MarkerComponent: IconAsset; if (index === 0) { MarkerComponent = Expensicons.DotIndicatorUnfilled; } else if (index === lastWaypointIndex) { @@ -82,8 +66,8 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig return { id: `${waypoint.lng},${waypoint.lat},${index}`, - coordinate: [waypoint.lng, waypoint.lat], - markerComponent: () => ( + coordinate: [waypoint.lng, waypoint.lat] as const, + markerComponent: (): ReactNode => ( ), }; - }), - (waypoint) => waypoint, - ), + }) + .filter((waypoint): waypoint is WayPoint => !!waypoint), [waypoints, lastWaypointIndex, theme.icon], ); @@ -105,7 +88,7 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig