diff --git a/.github/actions/composite/setupGitForOSBotify/action.yml b/.github/actions/composite/setupGitForOSBotify/action.yml index 0c06e2f4e169..c61fa7e934fd 100644 --- a/.github/actions/composite/setupGitForOSBotify/action.yml +++ b/.github/actions/composite/setupGitForOSBotify/action.yml @@ -20,7 +20,7 @@ runs: - name: Set up git for OSBotify shell: bash run: | - git config user.signingkey 367811D53E34168C + git config user.signingkey AEE1036472A782AB git config commit.gpgsign true git config user.name OSBotify git config user.email infra+osbotify@expensify.com diff --git a/.github/actions/composite/setupGitForOSBotifyApp/action.yml b/.github/actions/composite/setupGitForOSBotifyApp/action.yml index a6c487705c56..404ddc55e954 100644 --- a/.github/actions/composite/setupGitForOSBotifyApp/action.yml +++ b/.github/actions/composite/setupGitForOSBotifyApp/action.yml @@ -50,7 +50,7 @@ runs: - name: Set up git for OSBotify shell: bash run: | - git config user.signingkey 367811D53E34168C + git config user.signingkey AEE1036472A782AB git config commit.gpgsign true git config user.name OSBotify git config user.email infra+osbotify@expensify.com diff --git a/.github/workflows/OSBotify-private-key.asc.gpg b/.github/workflows/OSBotify-private-key.asc.gpg index c19d5c97866c..03f06222d0fe 100644 Binary files a/.github/workflows/OSBotify-private-key.asc.gpg and b/.github/workflows/OSBotify-private-key.asc.gpg differ diff --git a/.github/workflows/deployNewHelp.yml b/.github/workflows/deployNewHelp.yml index 8c4d0fb0ae3b..ec91f593e4b1 100644 --- a/.github/workflows/deployNewHelp.yml +++ b/.github/workflows/deployNewHelp.yml @@ -1,21 +1,24 @@ name: Deploy New Help Site on: - # Run on any push to main that has changes to the help directory -# TEST: Verify Cloudflare picks this up even if not run when merged to main -# push: -# branches: -# - main -# paths: -# - 'help/**' + # Run on any push to main that has changes to the help directory. This will cause this + # to deploy the latest code to newhelp.expensify.com + push: + branches: + - main + paths: + - 'help/**' + - './.github/workflows/deployNewHelp.yml' - # Run on any pull request (except PRs against staging or production) that has changes to the help directory + # Run on any pull request (except PRs against staging or production) that has + # changes to the help directory. This will cause it to deploy this unmerged branch to + # a Cloudflare "preview" environment pull_request: types: [opened, synchronize] branches-ignore: [staging, production] paths: - 'help/**' - + - './.github/workflows/deployNewHelp.yml' # Run on any manual trigger workflow_dispatch: @@ -27,10 +30,17 @@ concurrency: jobs: build: env: + # Open source contributors do not have write access to the Expensify/App repo, + # so must submit PRs from forks. This variable detects if the PR is coming + # from a fork, and thus is from an outside contributor. IS_PR_FROM_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} + + # Set up a clean Ubuntu build environment runs-on: ubuntu-latest steps: + # We start by checking out the entire repo into a clean build environment within + # the Github Action - name: Checkout code uses: actions/checkout@v4 @@ -41,34 +51,60 @@ jobs: bundler-cache: true working-directory: ./help + # Manually run Jekyll, bypassing Github Pages - name: Build Jekyll site run: bundle exec jekyll build --source ./ --destination ./_site working-directory: ./help # Ensure Jekyll is building the site in /help + # This will copy the contents of /help/_site to Cloudflare. The pages-action will + # evaluate the current branch to determine into which CF environment to deploy: + # - If you are on 'main', it will deploy to 'production' in Cloudflare + # - Otherwise it will deploy to a 'preview' environment made for this branch - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 - id: deploy - if: env.IS_PR_FROM_FORK != 'true' + id: cloudflarePagesAction + if: ${{ env.IS_PR_FROM_FORK != 'true' }} with: apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} projectName: newhelp directory: ./help/_site # Deploy the built site + # After deploying Cloudflare preview build, share wherever it deployed to in the PR comment. + - name: Leave a comment on the PR + uses: actions-cool/maintain-one-comment@v3.2.0 + if: ${{ github.event_name == 'pull_request' && env.IS_PR_FROM_FORK != 'true' }} + with: + token: ${{ github.token }} + body: ${{ format('Your New Help changes have been deployed to {0} :zap:️', steps.cloudflarePagesAction.outputs.alias) }} + + - name: Get merged pull request + if: ${{ github.event_name == 'push' }} + id: getMergedPullRequest + uses: actions-ecosystem/action-get-merged-pull-request@59afe90821bb0b555082ce8ff1e36b03f91553d9 + with: + github_token: ${{ github.token }} + + - name: Leave a comment on the PR after it's merged + if: ${{ github.event_name == 'push' }} + run: | + gh pr comment ${{ steps.getMergedPullRequest.outputs.number }} --body "$(cat <<'EOF' + 🚀Deployed to [NewHelp production](https://newhelp.expensify.com)! 🚀 + + ([_View deploy workflow run_](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})) + EOF + )" + env: + GITHUB_TOKEN: ${{ github.token }} + + # Use the Cloudflare CLI... - name: Setup Cloudflare CLI - if: env.IS_PR_FROM_FORK != 'true' + if: ${{ env.IS_PR_FROM_FORK != 'true' }} run: pip3 install cloudflare==2.19.0 + # ... to purge the cache, such that all users will see the latest content. - name: Purge Cloudflare cache - if: env.IS_PR_FROM_FORK != 'true' + if: ${{ env.IS_PR_FROM_FORK != 'true' }} run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["newhelp.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache env: CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} - - - name: Leave a comment on the PR - uses: actions-cool/maintain-one-comment@v3.2.0 - if: ${{ github.event_name == 'pull_request' && env.IS_PR_FROM_FORK != 'true' }} - with: - token: ${{ github.token }} - body: ${{ format('A preview of your New Help changes have been deployed to {0} :zap:️', steps.deploy.outputs.alias) }} - diff --git a/android/app/build.gradle b/android/app/build.gradle index 3a620c00fd95..e7632dd7b5e3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009004103 - versionName "9.0.41-3" + versionCode 1009004200 + versionName "9.0.42-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/table.svg b/assets/images/table.svg index 36d4ced774f1..dea1e990b97d 100644 --- a/assets/images/table.svg +++ b/assets/images/table.svg @@ -1,3 +1,3 @@ - - + + diff --git a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md index 03dd3d722d82..14b5225801d0 100644 --- a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md +++ b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md @@ -36,10 +36,6 @@ Once the member verifies their email address, all Domain Admins will be notified 3. Click the **Domain Members** tab on the left. 4. Under the Domain Members section, enter the first part of the member’s email address and click **Invite**. -{% include info.html %} -This can be any email address—it does not have to be an email address under the domain. If someone who is not a Domain Admin invites a new member to a workspace, that member must validate their account via email before they will have access to it. -{% include end-info.html %} - # Add Domain Admin 1. Hover over Settings, then click **Domains**. diff --git a/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md b/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md index 3927ec5b7a33..54314e0edb4d 100644 --- a/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md +++ b/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md @@ -6,6 +6,18 @@ description: Use 2FA for extra login security Add an extra layer of security to help keep your financial data safe and secure by enabling two-factor authentication (2FA). This will require you to enter a code generated by your preferred authenticator app (like Google Authenticator or Microsoft Authenticator) when you log in. +Expensify's Two-Factor Authentication (2FA) is implemented via a Time-based One-Time Password (TOTP) algorithm. This requires you to use an Authenticator app to generate a unique code each time you log in, adding a second “factor” to your login. + +You can choose to use whichever authenticator you prefer, but here are a few we recommend: +- [1Password](https://support.1password.com/one-time-passwords/) +- [Authy](https://authy.com/) +- [Google Authenticator](https://support.google.com/accounts/answer/1066447) +- [Microsoft Authenticator](https://www.microsoft.com/en-us/security/mobile-authenticator-app) + +You will need to select an authenticator app to use before proceeding. + +## Enable and Set Up Two-factor authentication + 1. Hover over Settings, then click **Account**. 2. Under the Account Details tab, scroll down to the Two Factor Authentication section and enable the toggle. 3. Save a copy of your backup codes. @@ -19,8 +31,32 @@ This step is critical—You will lose access to your account if you cannot use y 4. Click **Continue**. 5. Download or open your authenticator app and either: - Scan the QR code shown on your computer screen. - - Enter the 6-digit code from your authenticator app into Expensify and click **Verify**. + - Enter the 6-digit code from your authenticator app into Expensify and click **Verify**. When you log in to Expensify in the future, you’ll be emailed a magic code that you’ll use to log in with. Then you’ll be prompted to open your authenticator app to get the 6-digit code and enter it into Expensify. A new code regenerates every few seconds, so the code is always different. If the code time runs out, you can generate a new code as needed. +## Lost recovery codes and authenticator app + +If you have lost your mobile device and can’t find your recovery codes, you can have your Domain Admin complete the steps below to reset your 2FA **only if you use a company email address or an email address on a domain that you own**: + +Go to Settings > Domains > Domain Members and click **Edit Settings** for your email address. +They then click **Reset** to reset two-factor authentication (2FA) on your account. + +This will allow you to gain access to your account on the web or mobile app and reconfigure 2FA again. + +{% include info.html %} +If you use a public email address such as gmail, hotmail, or yahoo, we unfortunately can’t help you disable your 2FA setting. If you are unable to find your recovery codes, you may need to create a new Expensify account with a different email address. +{% include end-info.html %} + +If you don’t have a Domain Admin, follow the steps in this [guide](https://help.expensify.com/articles/expensify-classic/domains/Claim-And-Verify-A-Domain) to verify the domain. + +## General troubleshooting + +Make sure your phone’s time is set to automatically update (a manual time that’s fractionally different can cause issues). +Try disabling 2FA using a device that you are still logged into. For example, if you’re having trouble logging in with your computer, try to see if your mobile device is still logged in. If so, +Hover over Settings, then click Account. +Under the Account Details tab, scroll down to the Two Factor Authentication section and disable the toggle. +Try logging in with your other device. +Once you’ve logged in again, you can re-enable 2FA. + diff --git a/help/README.md b/help/README.md new file mode 100644 index 000000000000..5145954923de --- /dev/null +++ b/help/README.md @@ -0,0 +1,50 @@ +# Welcome to New Help! +Here are some instructions on how to get started with New Help... + +## How to contribute +Expensify is an open source app, with its public Github repo hosted at https://github.com/Expensify/App. The newhelp.expensify.com website is a part of that same open source project. You can contribute to this helpsite in one of two ways: + +### The hard way: local dev environment +If you are a developer comfortable working on the command line, you can edit these files as follows: + +1. Fork https://github.com/Expensify/App repo + * `...tbd...` +2. Install Homebrew: https://brew.sh/ +3. Install `rbenv` using brew: + * `brew install rbenv` +4. Install ruby v3.3.4 using + * `rbenv install 3.3.4` +5. Set the your default ruby version using + * `rbenv global 3.3.4` +6. Install Jekyll and bundler gem + * `cd help` + * `gem install jekyll bundler` +7. Create a branch for your changes +8. Make your changes +9. Locally build and test your changes: + * `bundle exec jekyll build` +10. Push your changes + +### The easy way: edit on Github +If you don't want to set up your own local dev environment, feel free to just edit the help materials directly from Github: + +1. Open whatever file you want. +2. Replace `github.com` with `github.dev` in the URL +3. Edit away! + +## How to add a page +The current design of NewHelp.expensify.com is only to have a very small handful pages (one for each "product"), each of which is a markdown file stored in `/help` using the `product` template (defined in `/help/_layouts/product.html`). Accordingly, it's very unlikely you'll be adding a new page. + +The goal is to use a system named Jekyll to do the heavy lifting of not just converting that Markdown into HTML, but also allowing for deep linking of the headers, auto-linking mentions of those titles elsewhere, and a ton more. So, just write a basic Markdown file, and it should handle the rest. + +## How to preview the site online +Every PR pushed by an authorized Expensify employee or representative will automatically trigger a "build" of the site using a Github Action. This will [follow these steps](../.github/workflows/deployNewHelp.yml) to: +1. Start a new Ubuntu server +2. Check out the repo +3. Install Ruby and Jekyll +4. Build the entire site using Jekyll +5. Create a "preview" of the newly built site in Cloudflare +6. Record a link to that preview in the PR. + +## How to deploy the site for real +Whenever a PR that touches the `/help` directory is merged, it will re-run the build just like before. However, it will detect that this build is being run from the `main` branch, and thus push the changes to the `production` Cloudflare environment -- meaning, it will replace the contents hosted at https://newhelp.expensify.com diff --git a/help/_config.yml b/help/_config.yml index 9135a372964e..11091b1a8b7c 100644 --- a/help/_config.yml +++ b/help/_config.yml @@ -5,3 +5,6 @@ url: https://newhelp.expensify.com twitter_username: expensify github_username: expensify +# Ignore what's only used for the Github repo +exclude: + - README.md diff --git a/help/index.md b/help/index.md index e5d075402ecb..b198c5e20781 100644 --- a/help/index.md +++ b/help/index.md @@ -1,5 +1,7 @@ --- title: New Expensify Help --- + Pages: -* [Expensify Superapp](/superapp.html) + +- [Expensify Superapp](/superapp.html) diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 35d71276f8ef..6cb62eaca2dc 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.41 + 9.0.42 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.41.3 + 9.0.42.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index ca0c99bb87a2..8909deaa074b 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.41 + 9.0.42 CFBundleSignature ???? CFBundleVersion - 9.0.41.3 + 9.0.42.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 2d97209598e2..4322d1bc53ad 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.41 + 9.0.42 CFBundleVersion - 9.0.41.3 + 9.0.42.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index beac64acd083..69989797ecb2 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2360,7 +2360,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.143): + - RNLiveMarkdown (0.1.159): - DoubleConversion - glog - hermes-engine @@ -2380,9 +2380,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/common (= 0.1.143) + - RNLiveMarkdown/common (= 0.1.159) - Yoga - - RNLiveMarkdown/common (0.1.143): + - RNLiveMarkdown/common (0.1.159): - DoubleConversion - glog - hermes-engine @@ -3229,7 +3229,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 8781e2529230a1bc3ea8d75e5c3cd071b6c6aed7 RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: e44918843c2638692348f39eafc275698baf0444 + RNLiveMarkdown: 2d97e3f4952c642cdd31bc05555e44dc5edcdba1 RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4 RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 diff --git a/package-lock.json b/package-lock.json index b50632923b00..ae3baac9ba82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "9.0.41-3", + "version": "9.0.42-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.41-3", + "version": "9.0.42-0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.143", + "@expensify/react-native-live-markdown": "0.1.159", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -71,7 +71,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.3.1", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "1.0.14", + "react-fast-pdf": "1.0.15", "react-map-gl": "^7.1.3", "react-native": "0.75.2", "react-native-android-location-enabler": "^2.0.1", @@ -117,7 +117,6 @@ "react-native-web": "^0.19.12", "react-native-web-sound": "^0.1.3", "react-native-webview": "13.8.6", - "react-pdf": "9.1.0", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", @@ -216,7 +215,7 @@ "electron-builder": "25.0.0", "eslint": "^8.57.0", "eslint-config-airbnb-typescript": "^18.0.0", - "eslint-config-expensify": "^2.0.59", + "eslint-config-expensify": "^2.0.60", "eslint-config-prettier": "^9.1.0", "eslint-plugin-deprecation": "^3.0.0", "eslint-plugin-jest": "^28.6.0", @@ -3634,9 +3633,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.143", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.143.tgz", - "integrity": "sha512-hZXYjKyTl/b2p7Ig9qhoB7cfVtTTcoE2cWvea8NJT3f5ZYckdyHDAgHI4pg0S0N68jP205Sk5pzqlltZUpZk5w==", + "version": "0.1.159", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.159.tgz", + "integrity": "sha512-UKjEdERbaG7FMH2+r6lSjnSoic1f3UL2r37qkuJ8vV5UKyG3Nw/akoZwt6kBuRKrM9DGzK6Yc2b9HGhR0/wUMA==", "workspaces": [ "parser", "example", @@ -22794,7 +22793,6 @@ "dev": true, "dependencies": { "@lwc/eslint-plugin-lwc": "^1.7.2", - "@typescript-eslint/parser": "^7.12.0", "@typescript-eslint/rule-tester": "^7.16.1", "@typescript-eslint/utils": "^7.12.0", "babel-eslint": "^10.1.0", @@ -34250,10 +34248,11 @@ } }, "node_modules/react-fast-pdf": { - "version": "1.0.14", - "license": "MIT", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.15.tgz", + "integrity": "sha512-xXrwIfRUD3KSRrBdfAeGnLZTf0kYUa+d6GGee1Hu0PFAv5QPBeF3tcV+DU+Cm/JMjSuR7s5g0KK9bePQ/xiQ+w==", "dependencies": { - "react-pdf": "^7.7.0", + "react-pdf": "^9.1.1", "react-window": "^1.8.10" }, "engines": { @@ -34262,7 +34261,7 @@ }, "peerDependencies": { "lodash": "4.x", - "prop-types": "15.x", + "pdfjs-dist": "4.x", "react": "18.x", "react-dom": "18.x" } @@ -36653,9 +36652,9 @@ } }, "node_modules/react-pdf": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.1.0.tgz", - "integrity": "sha512-KhPDQE3QshkLdS3b48S5Bldv0N5flob6qwvsiADWdZOS5TMDaIrkRtEs+Dyl6ubRf2jTf9jWmFb6RjWu46lSSg==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.1.1.tgz", + "integrity": "sha512-Cn3RTJZMqVOOCgLMRXDamLk4LPGfyB2Np3OwQAUjmHIh47EpuGW1OpAA1Z1GVDLoHx4d5duEDo/YbUkDbr4QFQ==", "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", @@ -41860,4 +41859,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 249c64e5e621..ac920465b47c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.41-3", + "version": "9.0.42-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -68,7 +68,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.143", + "@expensify/react-native-live-markdown": "0.1.159", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -128,7 +128,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.3.1", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "1.0.14", + "react-fast-pdf": "1.0.15", "react-map-gl": "^7.1.3", "react-native": "0.75.2", "react-native-android-location-enabler": "^2.0.1", @@ -174,22 +174,12 @@ "react-native-web": "^0.19.12", "react-native-web-sound": "^0.1.3", "react-native-webview": "13.8.6", - "react-pdf": "9.1.0", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", "react-window": "^1.8.9" }, "devDependencies": { - "@fullstory/babel-plugin-react-native": "^1.2.1", - "@kie/act-js": "^2.6.2", - "@kie/mock-github": "2.0.1", - "@vue/preload-webpack-plugin": "^2.0.0", - "jest-expo": "51.0.4", - "jest-when": "^3.5.2", - "react-compiler-runtime": "file:./lib/react-compiler-runtime", - "semver": "7.5.2", - "xlsx": "file:vendor/xlsx-0.20.3.tgz", "@actions/core": "1.10.0", "@actions/github": "5.1.1", "@babel/core": "^7.20.0", @@ -198,6 +188,7 @@ "@babel/plugin-proposal-export-namespace-from": "^7.18.9", "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/preset-env": "^7.20.0", "@babel/preset-flow": "^7.12.13", "@babel/preset-react": "^7.10.4", @@ -209,7 +200,10 @@ "@dword-design/eslint-plugin-import-alias": "^5.0.0", "@electron/notarize": "^2.1.0", "@fullstory/babel-plugin-annotate-react": "^2.3.0", + "@fullstory/babel-plugin-react-native": "^1.2.1", "@jest/globals": "^29.5.0", + "@kie/act-js": "^2.6.2", + "@kie/mock-github": "2.0.1", "@ngneat/falso": "^7.1.1", "@octokit/core": "4.0.4", "@octokit/plugin-paginate-rest": "3.1.0", @@ -258,6 +252,7 @@ "@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/parser": "^7.13.1", "@vercel/ncc": "0.38.1", + "@vue/preload-webpack-plugin": "^2.0.0", "@welldone-software/why-did-you-render": "7.0.1", "ajv-cli": "^5.0.0", "babel-jest": "29.4.1", @@ -265,7 +260,6 @@ "babel-plugin-module-resolver": "^5.0.0", "babel-plugin-react-compiler": "0.0.0-experimental-334f00b-20240725", "babel-plugin-react-native-web": "^0.18.7", - "@babel/plugin-transform-class-properties": "^7.25.4", "babel-plugin-transform-remove-console": "^6.9.4", "clean-webpack-plugin": "^4.0.0", "concurrently": "^8.2.2", @@ -278,24 +272,26 @@ "electron-builder": "25.0.0", "eslint": "^8.57.0", "eslint-config-airbnb-typescript": "^18.0.0", - "eslint-config-expensify": "^2.0.59", + "eslint-config-expensify": "^2.0.60", "eslint-config-prettier": "^9.1.0", "eslint-plugin-deprecation": "^3.0.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-jsdoc": "^46.2.6", + "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-react-compiler": "0.0.0-experimental-9ed098e-20240725", "eslint-plugin-react-native-a11y": "^3.3.0", "eslint-plugin-storybook": "^0.8.0", "eslint-plugin-testing-library": "^6.2.2", "eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0", - "eslint-plugin-lodash": "^7.4.0", "html-webpack-plugin": "^5.5.0", "http-server": "^14.1.1", "jest": "29.4.1", "jest-circus": "29.4.1", "jest-cli": "29.4.1", "jest-environment-jsdom": "^29.4.1", + "jest-expo": "51.0.4", "jest-transformer-svg": "^2.0.1", + "jest-when": "^3.5.2", "link": "^2.1.1", "memfs": "^4.6.0", "onchange": "^7.1.0", @@ -306,10 +302,12 @@ "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", "react-compiler-healthcheck": "^0.0.0-experimental-ab3118d-20240725", + "react-compiler-runtime": "file:./lib/react-compiler-runtime", "react-is": "^18.3.1", "react-native-clean-project": "^4.0.0-alpha4.0", "react-test-renderer": "18.3.1", "reassure": "^1.0.0-rc.4", + "semver": "7.5.2", "setimmediate": "^1.0.5", "shellcheck": "^1.1.0", "source-map": "^0.7.4", @@ -326,7 +324,8 @@ "webpack-bundle-analyzer": "^4.5.0", "webpack-cli": "^5.0.4", "webpack-dev-server": "^5.0.4", - "webpack-merge": "^5.8.0" + "webpack-merge": "^5.8.0", + "xlsx": "file:vendor/xlsx-0.20.3.tgz" }, "overrides": { "react-native": "0.75.2", @@ -338,7 +337,6 @@ "yargs-parser": "21.1.1", "@expo/config-plugins": "8.0.4", "ws": "8.17.1", - "react-pdf": "9.1.0", "micromatch": "4.0.8", "json5": "2.2.2", "loader-utils": "2.0.4", @@ -379,4 +377,4 @@ "node": "20.15.1", "npm": "10.7.0" } -} +} \ No newline at end of file diff --git a/patches/react-fast-pdf+1.0.14.patch b/patches/react-fast-pdf+1.0.14.patch deleted file mode 100644 index 78a47bfb1b58..000000000000 --- a/patches/react-fast-pdf+1.0.14.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/react-fast-pdf/dist/PDFPreviewer.js b/node_modules/react-fast-pdf/dist/PDFPreviewer.js -index 4407807..ea3964d 100644 ---- a/node_modules/react-fast-pdf/dist/PDFPreviewer.js -+++ b/node_modules/react-fast-pdf/dist/PDFPreviewer.js -@@ -28,7 +28,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { - Object.defineProperty(exports, "__esModule", { value: true }); - // @ts-expect-error - This line imports a module from 'pdfjs-dist' package which lacks TypeScript typings. - // eslint-disable-next-line import/no-extraneous-dependencies --const pdf_worker_1 = __importDefault(require("pdfjs-dist/legacy/build/pdf.worker")); -+const pdf_worker_1 = __importDefault(require("pdfjs-dist/legacy/build/pdf.worker.mjs")); - const react_1 = __importStar(require("react")); - const times_1 = __importDefault(require("lodash/times")); - const prop_types_1 = __importDefault(require("prop-types")); diff --git a/patches/react-pdf+9.1.0.patch b/patches/react-pdf+9.1.0.patch deleted file mode 100644 index f046202de9c2..000000000000 --- a/patches/react-pdf+9.1.0.patch +++ /dev/null @@ -1,24 +0,0 @@ -diff --git a/node_modules/react-pdf/dist/cjs/Document.js b/node_modules/react-pdf/dist/cjs/Document.js -index ed7114d..43d648b 100644 ---- a/node_modules/react-pdf/dist/cjs/Document.js -+++ b/node_modules/react-pdf/dist/cjs/Document.js -@@ -281,6 +281,7 @@ const Document = (0, react_1.forwardRef)(function Document(_a, ref) { - pdfDispatch({ type: 'REJECT', error }); - }); - return () => { -+ loadingTask._worker.destroy(); - loadingTask.destroy(); - }; - }, [options, pdfDispatch, source]); -diff --git a/node_modules/react-pdf/dist/esm/Document.js b/node_modules/react-pdf/dist/esm/Document.js -index 997a370..894e3c9 100644 ---- a/node_modules/react-pdf/dist/esm/Document.js -+++ b/node_modules/react-pdf/dist/esm/Document.js -@@ -253,6 +253,7 @@ const Document = forwardRef(function Document(_a, ref) { - pdfDispatch({ type: 'REJECT', error }); - }); - return () => { -+ loadingTask._worker.destroy(); - loadingTask.destroy(); - }; - }, [options, pdfDispatch, source]); diff --git a/src/CONST.ts b/src/CONST.ts index 5af0b34c0252..7d1e866fac86 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -171,7 +171,7 @@ const CONST = { }, // Note: Group and Self-DM excluded as these are not tied to a Workspace - WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT, chatTypes.INVOICE], + WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT], ANDROID_PACKAGE_NAME, WORKSPACE_ENABLE_FEATURE_REDIRECT_DELAY: 100, ANIMATED_HIGHLIGHT_ENTRY_DELAY: 50, @@ -4550,12 +4550,12 @@ const CONST = { 'Here’s how to set up categories:\n' + '\n' + '1. Click your profile picture.\n' + - '2. Go to Workspaces.\n' + + '2. Go to *Workspaces*.\n' + '3. Select your workspace.\n' + '4. Click *Categories*.\n' + - '5. Enable and disable default categories.\n' + - '6. Click *Add categories* to make your own.\n' + - '7. For more controls like requiring a category for every expense, click *Settings*.\n' + + '5. Add or import your own categories.\n' + + "6. Disable any default categories you don't need.\n" + + '7. Require a category for every expense in *Settings*.\n' + '\n' + `[Take me to workspace category settings](${workspaceCategoriesLink}).`, }, diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 62e7839b21f0..f5d4655c4861 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -20,7 +20,6 @@ import {updateLastRoute} from './libs/actions/App'; import * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; import * as Report from './libs/actions/Report'; import * as User from './libs/actions/User'; -import {handleHybridAppOnboarding} from './libs/actions/Welcome'; import * as ActiveClientManager from './libs/ActiveClientManager'; import FS from './libs/Fullstory'; import * as Growl from './libs/Growl'; @@ -99,7 +98,6 @@ function Expensify({ const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [session] = useOnyx(ONYXKEYS.SESSION); const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE); - const [tryNewDotData] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT); const [shouldShowRequire2FAModal, setShouldShowRequire2FAModal] = useState(false); useEffect(() => { @@ -118,14 +116,6 @@ function Expensify({ setAttemptedToOpenPublicRoom(true); }, [isCheckingPublicRoom]); - useEffect(() => { - if (splashScreenState !== CONST.BOOT_SPLASH_STATE.HIDDEN || tryNewDotData === undefined) { - return; - } - - handleHybridAppOnboarding(); - }, [splashScreenState, tryNewDotData]); - const isAuthenticated = useMemo(() => !!(session?.authToken ?? null), [session]); const autoAuthState = useMemo(() => session?.autoAuthState ?? '', [session]); diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index dd6534218359..2f62e6a813b5 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -112,7 +112,7 @@ const getDataForUpload = (fileData: FileResponse): Promise => { * a callback. This is the ios/android implementation * opening a modal with attachment options */ -function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, shouldHideCameraOption = false}: AttachmentPickerProps) { +function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, shouldHideCameraOption = false, shouldValidateImage = true}: AttachmentPickerProps) { const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); @@ -321,6 +321,26 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s width: ('width' in fileData && fileData.width) || undefined, height: ('height' in fileData && fileData.height) || undefined, }; + + if (!shouldValidateImage && fileDataName && Str.isImage(fileDataName)) { + ImageSize.getSize(fileDataUri) + .then(({width, height}) => { + fileDataObject.width = width; + fileDataObject.height = height; + return fileDataObject; + }) + .then((file) => { + getDataForUpload(file) + .then((result) => { + completeAttachmentSelection.current(result); + }) + .catch((error: Error) => { + showGeneralAlert(error.message); + throw error; + }); + }); + return; + } /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ if (fileDataName && Str.isImage(fileDataName)) { ImageSize.getSize(fileDataUri) @@ -334,7 +354,7 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s return validateAndCompleteAttachmentSelection(fileDataObject); } }, - [validateAndCompleteAttachmentSelection, showImageCorruptionAlert], + [validateAndCompleteAttachmentSelection, showImageCorruptionAlert, shouldValidateImage, showGeneralAlert], ); /** diff --git a/src/components/AttachmentPicker/types.ts b/src/components/AttachmentPicker/types.ts index 057ec72de27e..38e8e5c73032 100644 --- a/src/components/AttachmentPicker/types.ts +++ b/src/components/AttachmentPicker/types.ts @@ -42,6 +42,9 @@ type AttachmentPickerProps = { type?: ValueOf; acceptedFileTypes?: Array>; + + /** Whether to validate the image and show the alert or not. */ + shouldValidateImage?: boolean; }; export default AttachmentPickerProps; diff --git a/src/components/AvatarSkeleton.tsx b/src/components/AvatarSkeleton.tsx index a88304b15fc3..6e0a4f407d70 100644 --- a/src/components/AvatarSkeleton.tsx +++ b/src/components/AvatarSkeleton.tsx @@ -17,6 +17,7 @@ function AvatarSkeleton({size = CONST.AVATAR_SIZE.SMALL}: {size?: ValueOf diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx index 011b7f510275..cdd43cb2555e 100644 --- a/src/components/AvatarWithImagePicker.tsx +++ b/src/components/AvatarWithImagePicker.tsx @@ -232,26 +232,31 @@ function AvatarWithImagePicker({ return; } - isValidResolution(image).then((isValid) => { - if (!isValid) { - setError('avatarWithImagePicker.resolutionConstraints', { - minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX, - minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX, - maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX, - maxWidthInPx: CONST.AVATAR_MAX_WIDTH_PX, + FileUtils.validateImageForCorruption(image) + .then(() => isValidResolution(image)) + .then((isValid) => { + if (!isValid) { + setError('avatarWithImagePicker.resolutionConstraints', { + minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX, + minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX, + maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX, + maxWidthInPx: CONST.AVATAR_MAX_WIDTH_PX, + }); + return; + } + + setIsAvatarCropModalOpen(true); + setError(null, {}); + setIsMenuVisible(false); + setImageData({ + uri: image.uri ?? '', + name: image.name ?? '', + type: image.type ?? '', }); - return; - } - - setIsAvatarCropModalOpen(true); - setError(null, {}); - setIsMenuVisible(false); - setImageData({ - uri: image.uri ?? '', - name: image.name ?? '', - type: image.type ?? '', + }) + .catch(() => { + setError('attachmentPicker.errorWhileSelectingCorruptedAttachment', {}); }); - }); }, [isValidExtension, isValidSize], ); @@ -339,7 +344,11 @@ function AvatarWithImagePicker({ maybeIcon={isUsingDefaultAvatar} > {({show}) => ( - + {({openPicker}) => { const menuItems = createMenuItems(openPicker); @@ -383,7 +392,7 @@ function AvatarWithImagePicker({ {source ? ( , @@ -48,8 +50,11 @@ function Composer( const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const [contextMenuHidden, setContextMenuHidden] = useState(!showSoftInputOnFocus); const {inputCallbackRef, inputRef: autoFocusInputRef} = useAutoFocusInput(); + const keyboardState = useKeyboardState(); + const isKeyboardShown = keyboardState?.isKeyboardShown ?? false; useEffect(() => { if (autoFocus === !!autoFocusInputRef.current) { @@ -109,6 +114,13 @@ function Composer( const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]); const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]); + useEffect(() => { + if (!showSoftInputOnFocus || !isKeyboardShown) { + return; + } + setContextMenuHidden(false); + }, [showSoftInputOnFocus, isKeyboardShown]); + return ( ); } diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 72116a346c00..55d14a116c3e 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -73,6 +73,7 @@ function Composer( isComposerFullSize = false, shouldContainScroll = true, isGroupPolicyReport = false, + showSoftInputOnFocus = true, ...props }: ComposerProps, ref: ForwardedRef, @@ -388,6 +389,7 @@ function Composer( value={value} defaultValue={defaultValue} autoFocus={autoFocus} + inputMode={!showSoftInputOnFocus ? 'none' : 'text'} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} onSelectionChange={addCursorPositionToSelectionChange} diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 41138970c547..616bc102c0f1 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -77,6 +77,8 @@ type ComposerProps = Omit & { /** Indicates whether the composer is in a group policy report. Used for disabling report mentioning style in markdown input */ isGroupPolicyReport?: boolean; + + showSoftInputOnFocus?: boolean; }; export type {TextSelection, ComposerProps, CustomSelectionChangeEvent}; diff --git a/src/components/ExplanationModal.tsx b/src/components/ExplanationModal.tsx index 73290c43d39a..d846dd4d28ba 100644 --- a/src/components/ExplanationModal.tsx +++ b/src/components/ExplanationModal.tsx @@ -1,30 +1,12 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import useLocalize from '@hooks/useLocalize'; -import Navigation from '@libs/Navigation/Navigation'; -import variables from '@styles/variables'; import * as Welcome from '@userActions/Welcome'; -import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import FeatureTrainingModal from './FeatureTrainingModal'; function ExplanationModal() { const {translate} = useLocalize(); - const onClose = useCallback(() => { - Welcome.completeHybridAppOnboarding(); - - // We need to check if standard NewDot onboarding is completed. - Welcome.isOnboardingFlowCompleted({ - onNotCompleted: () => { - setTimeout(() => { - Navigation.isNavigationReady().then(() => { - OnboardingFlow.startOnboardingFlow(); - }); - }, variables.welcomeVideoDelay); - }, - }); - }, []); - return ( ); } diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 94d5194be586..94469b10a713 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -21,7 +21,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import DomUtils from '@libs/DomUtils'; -import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector'; +import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import Performance from '@libs/Performance'; @@ -47,7 +47,7 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${optionItem?.reportID || -1}`); const [isFirstTimeNewExpensifyUser] = useOnyx(ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER); - const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { + const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasCompletedGuidedSetupFlowSelector, }); const [shouldHideGBRTooltip] = useOnyx(ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP, {initialValue: true}); @@ -171,12 +171,7 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti > {}, shouldShowPendingConversionMessage = false, isHovered = false, isWhisper = false, + transactionViolations, shouldDisplayContextMenu = true, - iouReportID, }: MoneyRequestPreviewProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -72,16 +78,6 @@ function MoneyRequestPreviewContent({ const {windowWidth} = useWindowDimensions(); const route = useRoute>(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID || '-1'}`); - const [session] = useOnyx(ONYXKEYS.SESSION); - const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID || '-1'}`); - - const isMoneyRequestAction = ReportActionsUtils.isMoneyRequestAction(action); - const transactionID = isMoneyRequestAction ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : '-1'; - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS); - const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const sessionAccountID = session?.accountID; const managerID = iouReport?.managerID ?? -1; diff --git a/src/components/ReportActionItem/MoneyRequestPreview/index.tsx b/src/components/ReportActionItem/MoneyRequestPreview/index.tsx index f902948b2cb5..c01206f83f55 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/index.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/index.tsx @@ -1,18 +1,44 @@ import lodashIsEmpty from 'lodash/isEmpty'; import React from 'react'; -import {useOnyx} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import MoneyRequestPreviewContent from './MoneyRequestPreviewContent'; -import type {MoneyRequestPreviewProps} from './types'; +import type {MoneyRequestPreviewOnyxProps, MoneyRequestPreviewProps} from './types'; function MoneyRequestPreview(props: MoneyRequestPreviewProps) { - const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${props.iouReportID || '-1'}`); // We should not render the component if there is no iouReport and it's not a split or track expense. // Moved outside of the component scope to allow for easier use of hooks in the main component. // eslint-disable-next-line react/jsx-props-no-spreading - return lodashIsEmpty(iouReport) && !(props.isBillSplit || props.isTrackExpense) ? null : ; + return lodashIsEmpty(props.iouReport) && !(props.isBillSplit || props.isTrackExpense) ? null : ; } MoneyRequestPreview.displayName = 'MoneyRequestPreview'; -export default MoneyRequestPreview; +export default withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + chatReport: { + key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, + }, + iouReport: { + key: ({iouReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + }, + session: { + key: ONYXKEYS.SESSION, + }, + transaction: { + key: ({action}) => { + const isMoneyRequestAction = ReportActionsUtils.isMoneyRequestAction(action); + const transactionID = isMoneyRequestAction ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : 0; + return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; + }, + }, + walletTerms: { + key: ONYXKEYS.WALLET_TERMS, + }, + transactionViolations: { + key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, + }, +})(MoneyRequestPreview); diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts index c40b45c6d2bd..021ae5d188d9 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts @@ -1,9 +1,33 @@ import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import type * as OnyxTypes from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; -type MoneyRequestPreviewProps = { +type MoneyRequestPreviewOnyxProps = { + /** All of the personal details for everyone */ + personalDetails: OnyxEntry; + + /** Chat report associated with iouReport */ + chatReport: OnyxEntry; + + /** IOU report data object */ + iouReport: OnyxEntry; + + /** Session info for the currently logged in user. */ + session: OnyxEntry; + + /** The transaction attached to the action.message.iouTransactionID */ + transaction: OnyxEntry; + + /** The transaction violations attached to the action.message.iouTransactionID */ + transactionViolations: OnyxCollection; + + /** Information about the user accepting the terms for payments */ + walletTerms: OnyxEntry; +}; + +type MoneyRequestPreviewProps = MoneyRequestPreviewOnyxProps & { /** The active IOUReport, used for Onyx subscription */ // The iouReportID is used inside withOnyx HOC // eslint-disable-next-line react/no-unused-prop-types @@ -66,4 +90,4 @@ type PendingProps = { type PendingMessageProps = PendingProps | NoPendingProps; -export type {MoneyRequestPreviewProps, PendingMessageProps}; +export type {MoneyRequestPreviewProps, MoneyRequestPreviewOnyxProps, PendingMessageProps}; diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 3408ffbc4803..50b84ae68469 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -1,21 +1,39 @@ import React, {useCallback, useContext, useMemo, useState} from 'react'; +import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import * as SearchUtils from '@libs/SearchUtils'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {SearchContext, SelectedTransactions} from './types'; const defaultSearchContext = { currentSearchHash: -1, selectedTransactions: {}, + selectedReports: [], setCurrentSearchHash: () => {}, setSelectedTransactions: () => {}, clearSelectedTransactions: () => {}, + shouldShowStatusBarLoading: false, + setShouldShowStatusBarLoading: () => {}, }; const Context = React.createContext(defaultSearchContext); +function getReportsFromSelectedTransactions(data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[], selectedTransactions: SelectedTransactions) { + return (data ?? []) + .filter( + (item) => + !SearchUtils.isTransactionListItemType(item) && + !SearchUtils.isReportActionListItemType(item) && + item.reportID && + item?.transactions?.every((transaction: {keyForList: string | number}) => selectedTransactions[transaction.keyForList]?.isSelected), + ) + .map((item) => item.reportID); +} + function SearchContextProvider({children}: ChildrenProps) { - const [searchContextData, setSearchContextData] = useState>({ + const [searchContextData, setSearchContextData] = useState>({ currentSearchHash: defaultSearchContext.currentSearchHash, selectedTransactions: defaultSearchContext.selectedTransactions, + selectedReports: defaultSearchContext.selectedReports, }); const setCurrentSearchHash = useCallback((searchHash: number) => { @@ -25,10 +43,14 @@ function SearchContextProvider({children}: ChildrenProps) { })); }, []); - const setSelectedTransactions = useCallback((selectedTransactions: SelectedTransactions) => { + const setSelectedTransactions = useCallback((selectedTransactions: SelectedTransactions, data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[]) => { + // When selecting transactions, we also need to manage the reports to which these transactions belong. This is done to ensure proper exporting to CSV. + const selectedReports = getReportsFromSelectedTransactions(data, selectedTransactions); + setSearchContextData((prevState) => ({ ...prevState, selectedTransactions, + selectedReports, })); }, []); @@ -40,19 +62,24 @@ function SearchContextProvider({children}: ChildrenProps) { setSearchContextData((prevState) => ({ ...prevState, selectedTransactions: {}, + selectedReports: [], })); }, [searchContextData.currentSearchHash], ); + const [shouldShowStatusBarLoading, setShouldShowStatusBarLoading] = useState(false); + const searchContext = useMemo( () => ({ ...searchContextData, setCurrentSearchHash, setSelectedTransactions, clearSelectedTransactions, + shouldShowStatusBarLoading, + setShouldShowStatusBarLoading, }), - [searchContextData, setCurrentSearchHash, setSelectedTransactions, clearSelectedTransactions], + [searchContextData, setCurrentSearchHash, setSelectedTransactions, clearSelectedTransactions, shouldShowStatusBarLoading], ); return {children}; diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index 689f917fdccf..36b56867b99f 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -56,7 +56,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const selectedOptions = useMemo(() => { return selectedReportIDs.map((id) => { const report = getSelectedOptionData(OptionsListUtils.createOptionFromReport({...reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`], reportID: id}, personalDetails)); - const alternateText = OptionsListUtils.getAlternateText(report, {showChatPreviewLine: true}); + const alternateText = OptionsListUtils.getAlternateText(report, {}); return {...report, alternateText}; }); }, [personalDetails, reports, selectedReportIDs]); @@ -65,7 +65,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen if (!areOptionsInitialized || !isScreenTransitionEnd) { return defaultListOptions; } - return OptionsListUtils.getSearchOptions(options); + return OptionsListUtils.getSearchOptions(options, '', undefined, false); }, [areOptionsInitialized, isScreenTransitionEnd, options]); const chatOptions = useMemo(() => { diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 2580298ac3ac..898de61aee64 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -1,17 +1,18 @@ -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import ConfirmModal from '@components/ConfirmModal'; +import DecisionModal from '@components/DecisionModal'; import Header from '@components/Header'; import type HeaderWithBackButtonProps from '@components/HeaderWithBackButton/types'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import {usePersonalDetails} from '@components/OnyxProvider'; -import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import Text from '@components/Text'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; @@ -29,7 +30,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; import {useSearchContext} from './SearchContext'; @@ -94,10 +95,6 @@ function HeaderWrapper({icon, title, subtitle, children, subtitleStyles = {}}: H type SearchPageHeaderProps = { queryJSON: SearchQueryJSON; hash: number; - onSelectDeleteOption?: (itemsToDelete: string[]) => void; - setOfflineModalOpen?: () => void; - setDownloadErrorModalOpen?: () => void; - data?: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[]; }; type SearchHeaderOptionValue = DeepValueOf | undefined; @@ -121,37 +118,26 @@ function getHeaderContent(type: SearchDataTypes): HeaderContent { } } -function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModalOpen, setDownloadErrorModalOpen, data}: SearchPageHeaderProps) { +function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {activeWorkspaceID} = useActiveWorkspace(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {selectedTransactions} = useSearchContext(); + const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); + const {selectedTransactions, clearSelectedTransactions, selectedReports} = useSearchContext(); const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = getAllTaxRates(); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false); + const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); + const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); - const selectedReports: Array = useMemo( - () => - (data ?? []) - .filter( - (item) => - !SearchUtils.isTransactionListItemType(item) && - !SearchUtils.isReportActionListItemType(item) && - item.reportID && - item.transactions.every((transaction: {keyForList: string | number}) => selectedTransactions[transaction.keyForList]?.isSelected), - ) - .map((item) => item.reportID), - [data, selectedTransactions], - ); const {status, type} = queryJSON; - const isCannedQuery = SearchUtils.isCannedSearchQuery(queryJSON); const headerSubtitle = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchUtils.getSearchHeaderTitle(queryJSON, personalDetails, cardList, reports, taxRates); @@ -159,6 +145,15 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa const headerIcon = isCannedQuery ? getHeaderContent(type).icon : Illustrations.Filters; const subtitleStyles = isCannedQuery ? styles.textHeadlineH2 : {}; + const handleDeleteExpenses = () => { + if (selectedTransactionsKeys.length === 0) { + return; + } + + clearSelectedTransactions(); + setIsDeleteExpensesConfirmModalVisible(false); + SearchActions.deleteMoneyRequestOnSearch(hash, selectedTransactionsKeys); + }; const headerButtonsOptions = useMemo(() => { if (selectedTransactionsKeys.length === 0) { @@ -174,7 +169,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setOfflineModalOpen?.(); + setIsOfflineModalVisible(true); return; } @@ -182,7 +177,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa SearchActions.exportSearchItemsToCSV( {query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys, policyIDs: [activeWorkspaceID ?? '']}, () => { - setDownloadErrorModalOpen?.(); + setIsDownloadErrorModalVisible(true); }, ); }, @@ -198,7 +193,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setOfflineModalOpen?.(); + setIsOfflineModalVisible(true); return; } @@ -217,7 +212,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setOfflineModalOpen?.(); + setIsOfflineModalVisible(true); return; } @@ -236,11 +231,10 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setOfflineModalOpen?.(); + setIsOfflineModalVisible(true); return; } - - onSelectDeleteOption?.(selectedTransactionsKeys); + setIsDeleteExpensesConfirmModalVisible(true); }, }); } @@ -270,14 +264,11 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa selectedTransactionsKeys, selectedTransactions, translate, - onSelectDeleteOption, hash, theme.icon, styles.colorMuted, styles.fontWeightNormal, isOffline, - setOfflineModalOpen, - setDownloadErrorModalOpen, activeWorkspaceID, selectedReports, styles.textWrap, @@ -286,52 +277,117 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa if (shouldUseNarrowLayout) { if (selectionMode?.isEnabled) { return ( - + + + { + setIsDeleteExpensesConfirmModalVisible(false); + }} + title={translate('iou.deleteExpense', {count: selectedTransactionsKeys.length})} + prompt={translate('iou.deleteConfirmation', {count: selectedTransactionsKeys.length})} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + setIsOfflineModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isOfflineModalVisible} + onClose={() => setIsOfflineModalVisible(false)} + /> + setIsDownloadErrorModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isDownloadErrorModalVisible} + onClose={() => setIsDownloadErrorModalVisible(false)} + /> + ); } return null; } const onPress = () => { - const values = SearchUtils.buildFilterFormValuesFromQuery(queryJSON); - SearchActions.updateAdvancedFilters(values); + const filterFormValues = SearchUtils.buildFilterFormValuesFromQuery(queryJSON); + SearchActions.updateAdvancedFilters(filterFormValues); + Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS); }; const displaySearchRouter = SearchUtils.isCannedSearchQuery(queryJSON); return ( - - <> - {headerButtonsOptions.length > 0 ? ( - null} - shouldAlwaysShowDropdownMenu - pressOnEnter - buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {count: selectedTransactionsKeys.length})} - options={headerButtonsOptions} - isSplitButton={false} - shouldUseStyleUtilityForAnchorPosition - /> - ) : ( - - )} - {displaySearchRouter && } - - + <> + + <> + {headerButtonsOptions.length > 0 ? ( + null} + shouldAlwaysShowDropdownMenu + pressOnEnter + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {count: selectedTransactionsKeys.length})} + options={headerButtonsOptions} + isSplitButton={false} + shouldUseStyleUtilityForAnchorPosition + /> + ) : ( + + )} + {displaySearchRouter && } + + + { + setIsDeleteExpensesConfirmModalVisible(false); + }} + title={translate('iou.deleteExpense', {count: selectedTransactionsKeys.length})} + prompt={translate('iou.deleteConfirmation', {count: selectedTransactionsKeys.length})} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + setIsOfflineModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isOfflineModalVisible} + onClose={() => setIsOfflineModalVisible(false)} + /> + setIsDownloadErrorModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isDownloadErrorModalVisible} + onClose={() => setIsDownloadErrorModalVisible(false)} + /> + ); } diff --git a/src/components/Search/SearchStatusBar.tsx b/src/components/Search/SearchStatusBar.tsx index ea19ef5f4e99..674b2da14f22 100644 --- a/src/components/Search/SearchStatusBar.tsx +++ b/src/components/Search/SearchStatusBar.tsx @@ -4,6 +4,7 @@ import type {ScrollView as RNScrollView} from 'react-native'; import Button from '@components/Button'; import * as Expensicons from '@components/Icon/Expensicons'; import ScrollView from '@components/ScrollView'; +import SearchStatusSkeleton from '@components/Skeletons/SearchStatusSkeleton'; import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -15,125 +16,127 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryString, SearchStatus, TripSearchStatus} from './types'; +import {useSearchContext} from './SearchContext'; +import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchStatus, TripSearchStatus} from './types'; type SearchStatusBarProps = { type: SearchDataTypes; status: SearchStatus; - resetOffset: () => void; + policyID: string | undefined; + onStatusChange?: () => void; }; -const expenseOptions: Array<{key: ExpenseSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ +const expenseOptions: Array<{status: ExpenseSearchStatus; type: SearchDataTypes; icon: IconAsset; text: TranslationPaths}> = [ { - key: CONST.SEARCH.STATUS.EXPENSE.ALL, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, icon: Expensicons.All, text: 'common.all', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.ALL), }, { - key: CONST.SEARCH.STATUS.EXPENSE.DRAFTS, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.DRAFTS, icon: Expensicons.Pencil, text: 'common.drafts', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.DRAFTS), }, { - key: CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING, icon: Expensicons.Hourglass, text: 'common.outstanding', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING), }, { - key: CONST.SEARCH.STATUS.EXPENSE.APPROVED, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.APPROVED, icon: Expensicons.ThumbsUp, text: 'iou.approved', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.APPROVED), }, { - key: CONST.SEARCH.STATUS.EXPENSE.PAID, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.PAID, icon: Expensicons.MoneyBag, text: 'iou.settledExpensify', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.PAID), }, ]; -const invoiceOptions: Array<{key: InvoiceSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ +const invoiceOptions: Array<{type: SearchDataTypes; status: InvoiceSearchStatus; icon: IconAsset; text: TranslationPaths}> = [ { - key: CONST.SEARCH.STATUS.INVOICE.ALL, + type: CONST.SEARCH.DATA_TYPES.INVOICE, + status: CONST.SEARCH.STATUS.INVOICE.ALL, icon: Expensicons.All, text: 'common.all', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.INVOICE, CONST.SEARCH.STATUS.INVOICE.ALL), }, { - key: CONST.SEARCH.STATUS.INVOICE.OUTSTANDING, + type: CONST.SEARCH.DATA_TYPES.INVOICE, + status: CONST.SEARCH.STATUS.INVOICE.OUTSTANDING, icon: Expensicons.Hourglass, text: 'common.outstanding', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.INVOICE, CONST.SEARCH.STATUS.INVOICE.OUTSTANDING), }, { - key: CONST.SEARCH.STATUS.INVOICE.PAID, + type: CONST.SEARCH.DATA_TYPES.INVOICE, + status: CONST.SEARCH.STATUS.INVOICE.PAID, icon: Expensicons.MoneyBag, text: 'iou.settledExpensify', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.INVOICE, CONST.SEARCH.STATUS.INVOICE.PAID), }, ]; -const tripOptions: Array<{key: TripSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ +const tripOptions: Array<{type: SearchDataTypes; status: TripSearchStatus; icon: IconAsset; text: TranslationPaths}> = [ { - key: CONST.SEARCH.STATUS.TRIP.ALL, + type: CONST.SEARCH.DATA_TYPES.TRIP, + status: CONST.SEARCH.STATUS.TRIP.ALL, icon: Expensicons.All, text: 'common.all', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.ALL), }, { - key: CONST.SEARCH.STATUS.TRIP.CURRENT, + type: CONST.SEARCH.DATA_TYPES.TRIP, + status: CONST.SEARCH.STATUS.TRIP.CURRENT, icon: Expensicons.Calendar, text: 'search.filters.current', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.CURRENT), }, { - key: CONST.SEARCH.STATUS.TRIP.PAST, + type: CONST.SEARCH.DATA_TYPES.TRIP, + status: CONST.SEARCH.STATUS.TRIP.PAST, icon: Expensicons.History, text: 'search.filters.past', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.PAST), }, ]; -const chatOptions: Array<{key: ChatSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ +const chatOptions: Array<{type: SearchDataTypes; status: ChatSearchStatus; icon: IconAsset; text: TranslationPaths}> = [ { - key: CONST.SEARCH.STATUS.CHAT.ALL, + type: CONST.SEARCH.DATA_TYPES.CHAT, + status: CONST.SEARCH.STATUS.CHAT.ALL, icon: Expensicons.All, text: 'common.all', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.ALL), }, { - key: CONST.SEARCH.STATUS.CHAT.UNREAD, + type: CONST.SEARCH.DATA_TYPES.CHAT, + status: CONST.SEARCH.STATUS.CHAT.UNREAD, icon: Expensicons.ChatBubbleUnread, text: 'common.unread', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.UNREAD), }, { - key: CONST.SEARCH.STATUS.CHAT.SENT, + type: CONST.SEARCH.DATA_TYPES.CHAT, + status: CONST.SEARCH.STATUS.CHAT.SENT, icon: Expensicons.Send, text: 'common.sent', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.SENT), }, { - key: CONST.SEARCH.STATUS.CHAT.ATTACHMENTS, + type: CONST.SEARCH.DATA_TYPES.CHAT, + status: CONST.SEARCH.STATUS.CHAT.ATTACHMENTS, icon: Expensicons.Document, text: 'common.attachments', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.ATTACHMENTS), }, { - key: CONST.SEARCH.STATUS.CHAT.LINKS, + type: CONST.SEARCH.DATA_TYPES.CHAT, + status: CONST.SEARCH.STATUS.CHAT.LINKS, icon: Expensicons.Paperclip, text: 'common.links', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.LINKS), }, { - key: CONST.SEARCH.STATUS.CHAT.PINNED, + type: CONST.SEARCH.DATA_TYPES.CHAT, + status: CONST.SEARCH.STATUS.CHAT.PINNED, icon: Expensicons.Pin, text: 'search.filters.pinned', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.PINNED), }, ]; @@ -151,7 +154,7 @@ function getOptions(type: SearchDataTypes) { } } -function SearchStatusBar({type, status, resetOffset}: SearchStatusBarProps) { +function SearchStatusBar({type, status, policyID, onStatusChange}: SearchStatusBarProps) { const {singleExecution} = useSingleExecution(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -160,26 +163,32 @@ function SearchStatusBar({type, status, resetOffset}: SearchStatusBarProps) { const options = getOptions(type); const scrollRef = useRef(null); const isScrolledRef = useRef(false); + const {shouldShowStatusBarLoading} = useSearchContext(); + + if (shouldShowStatusBarLoading) { + return ; + } return ( {options.map((item, index) => { const onPress = singleExecution(() => { - resetOffset(); - Navigation.setParams({q: item.query}); + onStatusChange?.(); + const query = SearchUtils.buildCannedSearchQuery({type: item.type, status: item.status, policyID}); + Navigation.setParams({q: query}); }); - const isActive = status === item.key; + const isActive = status === item.status; const isFirstItem = index === 0; const isLastItem = index === options.length - 1; return ( ); } diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx index 4b42aff1fc1d..972363bed7fc 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx @@ -4,8 +4,7 @@ import {Str} from 'expensify-common'; import lodashPick from 'lodash/pick'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -27,10 +26,8 @@ import withPolicy from '@pages/workspace/withPolicy'; import * as BankAccounts from '@userActions/BankAccounts'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {InputID} from '@src/types/form/ReimbursementAccountForm'; -import type * as OnyxTypes from '@src/types/onyx'; import type {ACHDataReimbursementAccount, BankAccountStep as TBankAccountStep} from '@src/types/onyx/ReimbursementAccount'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import ACHContractStep from './ACHContractStep'; @@ -42,32 +39,7 @@ import ContinueBankAccountSetup from './ContinueBankAccountSetup'; import EnableBankAccount from './EnableBankAccount/EnableBankAccount'; import RequestorStep from './RequestorStep'; -type ReimbursementAccountOnyxProps = { - /** Plaid SDK token to use to initialize the widget */ - plaidLinkToken: OnyxEntry; - - /** Plaid SDK current event */ - plaidCurrentEvent: OnyxEntry; - - /** Indicated whether the app is loading */ - isLoadingApp: OnyxEntry; - - /** Holds information about the users account that is logging in */ - account: OnyxEntry; - - /** Current session for the user */ - session: OnyxEntry; - - /** ACH data for the withdrawal account actively being set up */ - reimbursementAccount: OnyxEntry; - - /** The token required to initialize the Onfido SDK */ - onfidoToken: OnyxEntry; -}; - -type ReimbursementAccountPageProps = WithPolicyOnyxProps & - ReimbursementAccountOnyxProps & - StackScreenProps; +type ReimbursementAccountPageProps = WithPolicyOnyxProps & StackScreenProps; const ROUTE_NAMES = { COMPANY: 'company', @@ -124,17 +96,15 @@ function getRouteForCurrentStep(currentStep: TBankAccountStep): ValueOf({ - // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM - reimbursementAccount: { - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - }, - session: { - key: ONYXKEYS.SESSION, - }, - plaidLinkToken: { - key: ONYXKEYS.PLAID_LINK_TOKEN, - }, - plaidCurrentEvent: { - key: ONYXKEYS.PLAID_CURRENT_EVENT, - }, - onfidoToken: { - key: ONYXKEYS.ONFIDO_TOKEN, - }, - isLoadingApp: { - key: ONYXKEYS.IS_LOADING_APP, - }, - account: { - key: ONYXKEYS.ACCOUNT, - }, - })(ReimbursementAccountPage), -); +export default withPolicy(ReimbursementAccountPage); diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index c116a9dc5fbc..7009764629ba 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -9,7 +9,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {usePersonalDetails} from '@components/OnyxProvider'; import ScrollView from '@components/ScrollView'; -import type {AdvancedFiltersKeys, SearchQueryJSON} from '@components/Search/types'; +import type {AdvancedFiltersKeys} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -233,8 +233,8 @@ function AdvancedSearchFilters() { const personalDetails = usePersonalDetails(); const currentType = searchAdvancedFilters?.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; - const queryString = useMemo(() => SearchUtils.buildQueryStringFromFilterFormValues(searchAdvancedFilters) || '', [searchAdvancedFilters]); - const queryJSON = useMemo(() => SearchUtils.buildSearchQueryJSON(queryString || SearchUtils.buildCannedSearchQuery()) ?? ({} as SearchQueryJSON), [queryString]); + const queryString = useMemo(() => SearchUtils.buildQueryStringFromFilterFormValues(searchAdvancedFilters), [searchAdvancedFilters]); + const queryJSON = useMemo(() => SearchUtils.buildSearchQueryJSON(queryString || SearchUtils.buildCannedSearchQuery()), [queryString]); const applyFiltersAndNavigate = () => { SearchActions.clearAllFilters(); @@ -248,7 +248,7 @@ function AdvancedSearchFilters() { const onSaveSearch = () => { const savedSearchKeys = Object.keys(savedSearches ?? {}); - if (savedSearches && savedSearchKeys.includes(String(queryJSON.hash))) { + if (!queryJSON || (savedSearches && savedSearchKeys.includes(String(queryJSON.hash)))) { // If the search is already saved, return early to prevent unnecessary API calls Navigation.dismissModal(); return; @@ -302,6 +302,8 @@ function AdvancedSearchFilters() { }; }); + const displaySearchButton = queryJSON && !SearchUtils.isCannedSearchQuery(queryJSON); + return ( <> @@ -323,8 +325,7 @@ function AdvancedSearchFilters() { })} - - {!SearchUtils.isCannedSearchQuery(queryJSON) && ( + {displaySearchButton && (