diff --git a/.github/actions/composite/buildAndroidE2EAPK/action.yml b/.github/actions/composite/buildAndroidE2EAPK/action.yml index 47e13f6313a0..08cb55cab9b9 100644 --- a/.github/actions/composite/buildAndroidE2EAPK/action.yml +++ b/.github/actions/composite/buildAndroidE2EAPK/action.yml @@ -53,7 +53,7 @@ runs: distribution: "oracle" java-version: "17" - - uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011 + - uses: ruby/setup-ruby@v1.187.0 with: ruby-version: "2.7" bundler-cache: true @@ -74,7 +74,7 @@ runs: shell: bash - name: Upload APK - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 + uses: actions/upload-artifact@v4 with: name: ${{ inputs.ARTIFACT_NAME }} path: ${{ inputs.APP_OUTPUT_PATH }} diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 0d5879217ea0..add4879d8de1 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -156,7 +156,7 @@ jobs: run: mkdir zip - name: Download baseline APK - uses: actions/download-artifact@348754975ef0295bfa2c111cba996120cfdf8a5d + uses: actions/download-artifact@v4 id: downloadBaselineAPK with: name: baseline-apk-${{ needs.buildBaseline.outputs.VERSION }} @@ -170,7 +170,7 @@ jobs: run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease.apk" - name: Download delta APK - uses: actions/download-artifact@348754975ef0295bfa2c111cba996120cfdf8a5d + uses: actions/download-artifact@v4 id: downloadDeltaAPK with: name: delta-apk-${{ needs.buildDelta.outputs.DELTA_REF }} @@ -184,7 +184,7 @@ jobs: - name: Copy e2e code into zip folder run: cp tests/e2e/dist/index.js zip/testRunner.ts - + - name: Copy profiler binaries into zip folder run: cp -r node_modules/@perf-profiler/android/cpp-profiler/bin zip/bin diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 640d1eaa1172..ba776f257a3c 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -62,7 +62,7 @@ jobs: java-version: '17' - name: Setup Ruby - uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011 + uses: ruby/setup-ruby@v1.187.0 with: ruby-version: '2.7' bundler-cache: true @@ -80,38 +80,42 @@ jobs: - name: Set version in ENV run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_ENV" - - name: Run Fastlane beta - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: bundle exec fastlane android beta + - name: Run Fastlane + run: bundle exec fastlane android ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} env: + RUBYOPT: '-rostruct' MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} - - - name: Run Fastlane production - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: bundle exec fastlane android production - env: VERSION: ${{ env.VERSION_CODE }} - name: Archive Android sourcemaps - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: android-sourcemap-${{ github.ref_name }} path: android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map - - name: Upload Android version to GitHub artifacts + - name: Upload Android build to GitHub artifacts if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: app-production-release.aab path: android/app/build/outputs/bundle/productionRelease/app-production-release.aab - - name: Upload Android version to Browser Stack + - name: Upload Android build to Browser Stack if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@./android/app/build/outputs/bundle/productionRelease/app-production-release.aab" env: BROWSERSTACK: ${{ secrets.BROWSERSTACK }} + - name: Upload Android build to GitHub Release + if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: | + RUN_ID="$(gh run list --workflow platformDeploy.yml --event push --branch ${{ github.event.release.tag_name }} --json databaseId --jq '.[0].databaseId')" + gh run download "$RUN_ID" --name app-production-release.aab + gh release upload ${{ github.event.release.tag_name }} app-production-release.aab + env: + GITHUB_TOKEN: ${{ github.token }} + - name: Warn deployers if Android production deploy failed if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} uses: 8398a7/action-slack@v3 @@ -147,9 +151,13 @@ jobs: env: DEVELOPER_ID_SECRET_PASSPHRASE: ${{ secrets.DEVELOPER_ID_SECRET_PASSPHRASE }} - - name: Build production desktop app - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: npm run desktop-build + - name: Build desktop app + run: | + if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then + npm run desktop-build + else + npm run desktop-build-staging + fi env: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} @@ -159,18 +167,17 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_PRODUCTION }} + - name: Upload desktop build to GitHub Workflow + uses: actions/upload-artifact@v4 + with: + name: NewExpensify.dmg + path: desktop-build/NewExpensify.dmg - - name: Build staging desktop app - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: npm run desktop-build-staging + - name: Upload desktop build to GitHub Release + if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: gh release upload ${{ github.event.release.tag_name }} desktop-build/NewExpensify.dmg env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_STAGING }} + GITHUB_TOKEN: ${{ github.token }} iOS: name: Build and deploy iOS @@ -191,7 +198,7 @@ jobs: uses: ./.github/actions/composite/setupNode - name: Setup Ruby - uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011 + uses: ruby/setup-ruby@v1.187.0 with: ruby-version: '2.7' bundler-cache: true @@ -236,43 +243,45 @@ jobs: env: LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + - name: Set iOS version in ENV + run: echo "IOS_VERSION=$(echo '${{ github.event.release.tag_name }}' | tr '-' '.')" >> "$GITHUB_ENV" + - name: Run Fastlane - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: bundle exec fastlane ios beta + run: bundle exec fastlane ios ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} env: APPLE_CONTACT_EMAIL: ${{ secrets.APPLE_CONTACT_EMAIL }} APPLE_CONTACT_PHONE: ${{ secrets.APPLE_CONTACT_PHONE }} APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }} APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} + VERSION: ${{ env.IOS_VERSION }} - name: Archive iOS sourcemaps - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ios-sourcemap-${{ github.ref_name }} path: main.jsbundle.map - - name: Upload iOS version to GitHub artifacts + - name: Upload iOS build to GitHub artifacts if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: New Expensify.ipa path: /Users/runner/work/App/App/New Expensify.ipa - - name: Upload iOS version to Browser Stack + - name: Upload iOS build to Browser Stack if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/Users/runner/work/App/App/New Expensify.ipa" env: BROWSERSTACK: ${{ secrets.BROWSERSTACK }} - - name: Set iOS version in ENV - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: echo "IOS_VERSION=$(echo '${{ github.event.release.tag_name }}' | tr '-' '.')" >> "$GITHUB_ENV" - - - name: Run Fastlane for App Store release + - name: Upload iOS build to GitHub Release if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: bundle exec fastlane ios production + run: | + RUN_ID="$(gh run list --workflow platformDeploy.yml --event push --branch ${{ github.event.release.tag_name }} --json databaseId --jq '.[0].databaseId')" + gh run download "$RUN_ID" --name 'New Expensify.ipa' + gh release upload ${{ github.event.release.tag_name }} 'New Expensify.ipa' env: - VERSION: ${{ env.IOS_VERSION }} + GITHUB_TOKEN: ${{ github.token }} - name: Warn deployers if iOS production deploy failed if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} @@ -314,41 +323,33 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1 - - name: Build web for production - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: npm run build - - - name: Build web for staging - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: npm run build-staging - - - name: Build storybook docs for production - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: npm run storybook-build - continue-on-error: true + - name: Build web + run: | + if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then + npm run build + else + npm run build-staging + fi - - name: Build storybook docs for staging - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: npm run storybook-build-staging + - name: Build storybook docs continue-on-error: true + run: | + if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then + npm run storybook-build + else + npm run storybook-build-staging + fi - - name: Deploy production to S3 - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist s3://expensify-cash/ && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://expensify-cash/.well-known/apple-app-site-association s3://expensify-cash/.well-known/apple-app-site-association && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://expensify-cash/.well-known/apple-app-site-association s3://expensify-cash/apple-app-site-association - - - name: Deploy staging to S3 - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist s3://staging-expensify-cash/ && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://staging-expensify-cash/.well-known/apple-app-site-association s3://staging-expensify-cash/.well-known/apple-app-site-association && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://staging-expensify-cash/.well-known/apple-app-site-association s3://staging-expensify-cash/apple-app-site-association - - - name: Purge production Cloudflare cache - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache + - name: Deploy to S3 + run: | + aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist ${{ env.S3_URL }}/ + aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{ env.S3_URL }}/.well-known/apple-app-site-association + aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{env.S3_URL }}/apple-app-site-association env: - CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} + S3_URL: s3://${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && '' || 'staging-' }}expensify-cash - - name: Purge staging Cloudflare cache - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["staging.new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache + - name: Purge Cloudflare cache + run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && '' || 'staging.' }}new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache env: CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 10912aaeb436..e0ff9296abc0 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -88,7 +88,7 @@ jobs: java-version: '17' - name: Setup Ruby - uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011 + uses: ruby/setup-ruby@v1.187.0 with: ruby-version: '2.7' bundler-cache: true @@ -117,6 +117,7 @@ jobs: id: runFastlaneBetaTest run: bundle exec fastlane android build_internal env: + RUBYOPT: '-rostruct' S3_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} S3_BUCKET: ad-hoc-expensify-cash @@ -125,7 +126,7 @@ jobs: MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} - name: Upload Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: android path: ./android_paths.json @@ -161,7 +162,7 @@ jobs: run: sudo xcode-select -switch /Applications/Xcode_15.0.1.app - name: Setup Ruby - uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011 + uses: ruby/setup-ruby@v1.187.0 with: ruby-version: '2.7' bundler-cache: true @@ -217,7 +218,7 @@ jobs: S3_REGION: us-east-1 - name: Upload Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ios path: ./ios_paths.json @@ -321,7 +322,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} - name: Download Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} - name: Read JSONs with android paths diff --git a/.storybook/webpack.config.ts b/.storybook/webpack.config.ts index a7811a5a387d..0aa38f2e3b82 100644 --- a/.storybook/webpack.config.ts +++ b/.storybook/webpack.config.ts @@ -100,6 +100,11 @@ const webpackConfig = ({config}: {config: Configuration}) => { }), ); + config.module.rules?.push({ + test: /\.lottie$/, + type: 'asset/resource', + }); + return config; }; diff --git a/android/app/build.gradle b/android/app/build.gradle index d6de6cf4fae0..681e61a6afd8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -108,8 +108,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009000603 - versionName "9.0.6-3" + versionCode 1009000802 + versionName "9.0.8-2" // 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/LaptopwithSecondScreenandHourglass.svg b/assets/images/LaptopwithSecondScreenandHourglass.svg new file mode 100644 index 000000000000..c838c82c55ce --- /dev/null +++ b/assets/images/LaptopwithSecondScreenandHourglass.svg @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/articles/expensify-classic/workspaces/Invoicing.md b/docs/articles/expensify-classic/workspaces/Invoicing.md deleted file mode 100644 index f692f8f8d62e..000000000000 --- a/docs/articles/expensify-classic/workspaces/Invoicing.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Expensify Invoicing -description: Expensify Invoicing supports your business with unlimited invoice sending and receiving, payments, and status tracking in one single location. ---- -# Overview -Expensify Invoicing lets you create and send invoices, receive payments, and track the status of your invoices with Expensify, regardless of whether your customer has an Expensify account. Invoicing is included with all Expensify subscriptions, no matter the plan — just pay the processing fee (2.9%) per transaction. - -# How to Set Up Expensify Invoicing - -**If you have a Group Workspace:** - -1. Log into your Expensify account from the web (not the mobile app) -3. Head to **Settings** > **Workspaces** > **Group** > [_Workspace Name_] > [**Invoices**](https://expensify.com/policy?param={"policyID":"20AB6A03EB9CE54D"}#invoices). - -**If you have an Individual Workspace:** - -1. Log into your Expensify account from the web (not the mobile app) -2. Head to **Settings** > **Workspaces** > **Individual** > [_Workspace Name_]> [**Invoices**](https://expensify.com/policy?param={"policyID":"BD5FB746D3B220D6"}#invoices). - -Here, you’ll be able to create a markup or add a payment account. Don’t forget you need a verified bank account to send or accept invoice payments via ACH. - -# Deep Dive - -To help your invoice stand out and look more professional, you can: - -- Add your logo -- Set your workspace currency to add default report-level fields -- Create additional report-level fields to display more details - -## Add a Logo - -From your Expensify account on the web (not the mobile app), go to **Settings** > **Account** > **Account Details**. Then click **Edit Photo** under _Your Details_ to upload your logo. - -## Set the Workspace Currency - -To set your currency, head to **Settings** > **Workspaces** > **Individual** or **Group** > **Reports**. This will add default report-level fields to your invoices. You can see these at the bottom of your [**Reports**](https://expensify.com/reports) page. - -Here are the default report-level fields based on common currencies: - -- GBP: VAT Number & Supplier Address (your company address) -- EUR: VAT Number & Supplier Address (your company address) -- AUD: ABN Number & Supplier Address (your company address) -- NZD: GST Number & Supplier Address (your company address) -- CAD: Business Number & Supplier Address (your company address) - -## Adding Additional Fields to Your Invoices - -In addition to the default report-level fields, you can create custom invoice fields. - -At the bottom of the same Reports page, under the _Add New Field_ section, you’ll have multiple options. - -- **Field Title**: This is the name of the field as displayed on your invoice. -- **Type**: You have the option to select a _text-based_ field, a _dropdown_ of selections, or a _date_ selector. -- **Report Type**: Select _Invoice_ to add the field to your invoices. - -Don’t forget to click the **Add** button once you’ve set your field parameters! - -For example, you may want to add a PO number, business address, website, or any other custom fields. - -_Please check the regulations in your local jurisdiction to ensure tax and business compliance._ - -## Removing Fields from Your Invoices - -If you want to delete a report field, click the red trashcan on the field in your **Workspace** > **Individual** or **Group** > **Report** settings to remove it from all future invoices. Unsent invoices will have a red **X** next to the report field, which you can click to remove before sending the invoice to your customer. diff --git a/docs/articles/expensify-classic/workspaces/Set-Up-Invoicing.md b/docs/articles/expensify-classic/workspaces/Set-Up-Invoicing.md new file mode 100644 index 000000000000..8ec279da29a6 --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Set-Up-Invoicing.md @@ -0,0 +1,51 @@ +--- +title: Expensify Invoicing +description: Expensify Invoicing offers the ability to send, receive, and track the status of payments in one location. +--- +Invoicing lets you create and send invoices, receive payments, and track the status of your invoices, regardless of whether the customer has an Expensify account. This feature is included with all Expensify subscriptions, no matter the plan — you'll just pay the processing fee (2.9%) per transaction. + +# Set Up Expensify Invoicing +Before using the invoice feature, you'll need to [connect a business bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Business-Bank-Accounts-USD) to Expensify. + +Then, do the following: +1. Log into your Expensify account from the web (not the mobile app) +2. Head to _**Settings > Workspaces > Workspace Name > [Invoices](https://expensify.com/policy?param={"policyID":"20AB6A03EB9CE54D"}#invoices)**_. + +Here, you’ll be able to create a markup or add a payment account. + +## Add a Logo + +From your Expensify account on the web, go to _**Settings > Account > Account Details**_. Then click **Edit Photo** under _Your Details_ to upload your company logo. + +## Set the Workspace Currency + +To set the currency, head to _**Settings** > **Workspaces** > **Reports**_. This will add default report-level fields to your invoices. You can see these at the bottom of the [**Reports**](https://expensify.com/reports) page. + +Below are the default report-level fields based on common currencies: +- GBP: VAT Number & Supplier Address (your company address) +- EUR: VAT Number & Supplier Address (your company address) +- AUD: ABN Number & Supplier Address (your company address) +- NZD: GST Number & Supplier Address (your company address) +- CAD: Business Number & Supplier Address (your company address) + +## Adding Additional Fields to Invoices + +In addition to the default report-level fields, you can create custom invoice fields. + +At the bottom of the same Reports page, under the _Add New Field_ section, you’ll have multiple options. + +- **Field Title**: This is the name of the field as displayed on your invoice. +- **Type**: You have the option to select a _text-based_ field, a _dropdown_ of selections, or a _date_ selector. +- **Report Type**: Select _Invoice_ to add the field to your invoices. + +Don’t forget to click the **Add** button once you’ve set your field parameters! + +For example, you may want to add a PO number, business address, website, or any other custom fields. + +_Please check the regulations in your local jurisdiction to ensure tax and business compliance._ + +## Removing Fields from Invoices + +If you want to delete a report field, click the red trashcan on the field under _**Settings** > **Workspaces** > **Reports**_. This will remove that field from all future invoices. + +Unsent invoices will have a red **X** next to the report field, which you can click to remove before sending the invoice to your customer. diff --git a/docs/redirects.csv b/docs/redirects.csv index 1a60d52c1749..c1249773bb82 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -217,4 +217,7 @@ https://help.expensify.com/articles/new-expensify/expenses/Set-up-your-wallet,ht https://help.expensify.com/articles/new-expensify/expenses/Split-an-expense,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Split-an-expense https://help.expensify.com/articles/new-expensify/expenses/Track-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Track-expenses https://help.expensify.com/articles/new-expensify/expenses/Unlock-a-Business-Bank-Account,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Unlock-a-Business-Bank-Account -https://help.expensify.com/articles/new-expensify/expenses/Validate-a-Business-Bank-Account,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Validate-a-Business-Bank-Account \ No newline at end of file +https://help.expensify.com/articles/expensify-classic/workspaces/tax-tracking,https://help.expensify.com/articles/expensify-classic/workspaces/Tax-Tracking +https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Approval-Workflows,https://help.expensify.com/articles/expensify-classic/reports/Assign-report-approvers-to-specific-employees +https://help.expensify.com/articles/expensify-classic/settings/Notification-Troubleshooting,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-Notifications +https://help.expensify.com/articles/new-expensify/expenses/Validate-a-Business-Bank-Account,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Validate-a-Business-Bank-Account diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index c08a5aae1b73..0374529721e7 100644 Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg index 61b6eeb84537..0a8c3c896f5f 100644 Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 3e0b46a292a7..fb193ac125fd 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.6 + 9.0.8 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.6.3 + 9.0.8.2 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index d13eca4d1cad..eeacc6a590d4 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.6 + 9.0.8 CFBundleSignature ???? CFBundleVersion - 9.0.6.3 + 9.0.8.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 5125a598997f..891575550ee3 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.6 + 9.0.8 CFBundleVersion - 9.0.6.3 + 9.0.8.2 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 45813001d07c..3076816e9ed4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.6-3", + "version": "9.0.8-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.6-3", + "version": "9.0.8-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -26,7 +26,7 @@ "@fullstory/react-native": "^1.4.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", - "@kie/act-js": "^2.6.0", + "@kie/act-js": "^2.6.2", "@kie/mock-github": "2.0.1", "@onfido/react-native-sdk": "10.6.0", "@react-native-camera-roll/camera-roll": "7.4.0", @@ -7285,9 +7285,10 @@ } }, "node_modules/@kie/act-js": { - "version": "2.6.0", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@kie/act-js/-/act-js-2.6.2.tgz", + "integrity": "sha512-i366cfWluUi55rPZ6e9/aWH4tnw3Q6W1CKh9Gz6QjTvbAtS4KnUUy33I9aMXS6uwa0haw6MSahMM37vmuFCVpQ==", "hasInstallScript": true, - "license": "SEE LICENSE IN LICENSE", "dependencies": { "@kie/mock-github": "^2.0.0", "adm-zip": "^0.5.10", diff --git a/package.json b/package.json index 1cd32c974031..02b8ce22cd20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.6-3", + "version": "9.0.8-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -81,7 +81,7 @@ "@fullstory/react-native": "^1.4.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", - "@kie/act-js": "^2.6.0", + "@kie/act-js": "^2.6.2", "@kie/mock-github": "2.0.1", "@onfido/react-native-sdk": "10.6.0", "@react-native-camera-roll/camera-roll": "7.4.0", diff --git a/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch b/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch index bf6decac0450..c679bdbf73b9 100644 --- a/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch +++ b/patches/@react-native-community+netinfo+11.2.1+002+turbomodule.patch @@ -2619,6 +2619,25 @@ index 6982220..b515270 100644 + readonly eventEmitter: NativeEventEmitter; }; export default _default; +diff --git a/node_modules/@react-native-community/netinfo/package.json b/node_modules/@react-native-community/netinfo/package.json +index 3c80db2..15d214d 100644 +--- a/node_modules/@react-native-community/netinfo/package.json ++++ b/node_modules/@react-native-community/netinfo/package.json +@@ -97,6 +97,14 @@ + "webpack-cli": "^3.3.10", + "webpack-dev-server": "^3.11.3" + }, ++ "codegenConfig": { ++ "name": "RNCNetInfoSpec", ++ "type": "modules", ++ "jsSrcsDir": "src/internal", ++ "android": { ++ "javaPackageName": "com.reactnativecommunity.netinfo" ++ } ++ }, + "repository": { + "type": "git", + "url": "https://github.com/react-native-netinfo/react-native-netinfo.git" diff --git a/node_modules/@react-native-community/netinfo/react-native-netinfo.podspec b/node_modules/@react-native-community/netinfo/react-native-netinfo.podspec index e34e728..9090eb1 100644 --- a/node_modules/@react-native-community/netinfo/react-native-netinfo.podspec @@ -2955,95 +2974,95 @@ index 878f7ba..0000000 --- a/node_modules/@react-native-community/netinfo/windows/.npmignore +++ /dev/null @@ -1,92 +0,0 @@ --*AppPackages* --*BundleArtifacts* -- --#OS junk files --[Tt]humbs.db --*.DS_Store -- --#Visual Studio files --*.[Oo]bj --*.user --*.aps --*.pch --*.vspscc --*.vssscc --*_i.c --*_p.c --*.ncb --*.suo --*.tlb --*.tlh --*.bak --*.[Cc]ache --*.ilk --*.log --*.lib --*.sbr --*.sdf --*.opensdf --*.opendb --*.unsuccessfulbuild --ipch/ --[Oo]bj/ --[Bb]in --[Dd]ebug*/ --[Rr]elease*/ --Ankh.NoLoad -- --# Visual C++ cache files --ipch/ --*.aps --*.ncb --*.opendb --*.opensdf --*.sdf --*.cachefile --*.VC.db --*.VC.VC.opendb -- --#MonoDevelop --*.pidb --*.userprefs -- --#Tooling --_ReSharper*/ --*.resharper --[Tt]est[Rr]esult* --*.sass-cache -- --#Project files --[Bb]uild/ -- --#Subversion files --.svn -- --# Office Temp Files --~$* -- --# vim Temp Files --*~ -- --#NuGet --packages/ --*.nupkg -- --#ncrunch --*ncrunch* --*crunch*.local.xml -- --# visual studio database projects --*.dbmdl -- --#Test files --*.testsettings -- --#Other files --*.DotSettings --.vs/ --*project.lock.json -- --#Files generated by the VS build --**/Generated Files/** -- +-*AppPackages* +-*BundleArtifacts* +- +-#OS junk files +-[Tt]humbs.db +-*.DS_Store +- +-#Visual Studio files +-*.[Oo]bj +-*.user +-*.aps +-*.pch +-*.vspscc +-*.vssscc +-*_i.c +-*_p.c +-*.ncb +-*.suo +-*.tlb +-*.tlh +-*.bak +-*.[Cc]ache +-*.ilk +-*.log +-*.lib +-*.sbr +-*.sdf +-*.opensdf +-*.opendb +-*.unsuccessfulbuild +-ipch/ +-[Oo]bj/ +-[Bb]in +-[Dd]ebug*/ +-[Rr]elease*/ +-Ankh.NoLoad +- +-# Visual C++ cache files +-ipch/ +-*.aps +-*.ncb +-*.opendb +-*.opensdf +-*.sdf +-*.cachefile +-*.VC.db +-*.VC.VC.opendb +- +-#MonoDevelop +-*.pidb +-*.userprefs +- +-#Tooling +-_ReSharper*/ +-*.resharper +-[Tt]est[Rr]esult* +-*.sass-cache +- +-#Project files +-[Bb]uild/ +- +-#Subversion files +-.svn +- +-# Office Temp Files +-~$* +- +-# vim Temp Files +-*~ +- +-#NuGet +-packages/ +-*.nupkg +- +-#ncrunch +-*ncrunch* +-*crunch*.local.xml +- +-# visual studio database projects +-*.dbmdl +- +-#Test files +-*.testsettings +- +-#Other files +-*.DotSettings +-.vs/ +-*project.lock.json +- +-#Files generated by the VS build +-**/Generated Files/** +- \ No newline at end of file diff --git a/patches/focus-trap+7.5.4.patch b/patches/focus-trap+7.5.4.patch new file mode 100644 index 000000000000..c7b2aef2b51f --- /dev/null +++ b/patches/focus-trap+7.5.4.patch @@ -0,0 +1,106 @@ +diff --git a/node_modules/focus-trap/dist/focus-trap.esm.js b/node_modules/focus-trap/dist/focus-trap.esm.js +index 10d56db..a6d76d8 100644 +--- a/node_modules/focus-trap/dist/focus-trap.esm.js ++++ b/node_modules/focus-trap/dist/focus-trap.esm.js +@@ -100,8 +100,8 @@ var isKeyForward = function isKeyForward(e) { + var isKeyBackward = function isKeyBackward(e) { + return isTabEvent(e) && e.shiftKey; + }; +-var delay = function delay(fn) { +- return setTimeout(fn, 0); ++var delay = function delay(fn, delayTime = 0) { ++ return setTimeout(() => setTimeout(fn, delayTime), 0); + }; + + // Array.find/findIndex() are not supported on IE; this replicates enough +@@ -283,7 +283,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) { + return node; + }; + var getInitialFocusNode = function getInitialFocusNode() { +- var node = getNodeForOption('initialFocus'); ++ var node = getNodeForOption('initialFocus', state.containers); + + // false explicitly indicates we want no initialFocus at all + if (node === false) { +@@ -744,7 +744,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) { + // that caused the focus trap activation. + state.delayInitialFocusTimer = config.delayInitialFocus ? delay(function () { + tryFocus(getInitialFocusNode()); +- }) : tryFocus(getInitialFocusNode()); ++ }, typeof config.delayInitialFocus === 'number' ? config.delayInitialFocus : undefined) : tryFocus(getInitialFocusNode()); + doc.addEventListener('focusin', checkFocusIn, true); + doc.addEventListener('mousedown', checkPointerDown, { + capture: true, +@@ -880,7 +880,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) { + tryFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation)); + } + onPostDeactivate === null || onPostDeactivate === void 0 || onPostDeactivate(); +- }); ++ }, typeof config.delayInitialFocus === 'number' ? config.delayInitialFocus : undefined); + }; + if (returnFocus && checkCanReturnFocus) { + checkCanReturnFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation)).then(finishDeactivation, finishDeactivation); +diff --git a/node_modules/focus-trap/index.d.ts b/node_modules/focus-trap/index.d.ts +index 400db1b..69f4b94 100644 +--- a/node_modules/focus-trap/index.d.ts ++++ b/node_modules/focus-trap/index.d.ts +@@ -16,7 +16,7 @@ declare module 'focus-trap' { + * `document.querySelector()` to find the DOM node), `false` to explicitly indicate + * an opt-out, or a function that returns a DOM node or `false`. + */ +- export type FocusTargetOrFalse = FocusTargetValueOrFalse | (() => FocusTargetValueOrFalse); ++ export type FocusTargetOrFalse = FocusTargetValueOrFalse | ((containers?: HTMLElement[]) => FocusTargetValueOrFalse | undefined); + + type MouseEventToBoolean = (event: MouseEvent | TouchEvent) => boolean; + type KeyboardEventToBoolean = (event: KeyboardEvent) => boolean; +@@ -185,7 +185,7 @@ declare module 'focus-trap' { + * This prevents elements within the focusable element from capturing + * the event that triggered the focus trap activation. + */ +- delayInitialFocus?: boolean; ++ delayInitialFocus?: boolean | number; + /** + * Default: `window.document`. Document where the focus trap will be active. + * This allows to use FocusTrap in an iFrame context. +diff --git a/node_modules/focus-trap/index.js b/node_modules/focus-trap/index.js +index de8e46a..bfc8b63 100644 +--- a/node_modules/focus-trap/index.js ++++ b/node_modules/focus-trap/index.js +@@ -63,8 +63,8 @@ const isKeyBackward = function (e) { + return isTabEvent(e) && e.shiftKey; + }; + +-const delay = function (fn) { +- return setTimeout(fn, 0); ++const delay = function (fn, delayTime = 0) { ++ return setTimeout(() => setTimeout(fn, delayTime), 0); + }; + + // Array.find/findIndex() are not supported on IE; this replicates enough +@@ -267,7 +267,7 @@ const createFocusTrap = function (elements, userOptions) { + }; + + const getInitialFocusNode = function () { +- let node = getNodeForOption('initialFocus'); ++ let node = getNodeForOption('initialFocus', state.containers); + + // false explicitly indicates we want no initialFocus at all + if (node === false) { +@@ -817,7 +817,7 @@ const createFocusTrap = function (elements, userOptions) { + state.delayInitialFocusTimer = config.delayInitialFocus + ? delay(function () { + tryFocus(getInitialFocusNode()); +- }) ++ }, typeof config.delayInitialFocus === 'number' ? config.delayInitialFocus : undefined) + : tryFocus(getInitialFocusNode()); + + doc.addEventListener('focusin', checkFocusIn, true); +@@ -989,7 +989,7 @@ const createFocusTrap = function (elements, userOptions) { + tryFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation)); + } + onPostDeactivate?.(); +- }); ++ }, typeof config.delayInitialFocus === 'number' ? config.delayInitialFocus : undefined); + }; + + if (returnFocus && checkCanReturnFocus) { diff --git a/src/CONST.ts b/src/CONST.ts index ca2651a51a7c..3f141905e84c 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -897,6 +897,10 @@ const CONST = { INDIVIDUAL: 'individual', BUSINESS: 'policy', }, + EXPORT_OPTIONS: { + EXPORT_TO_INTEGRATION: 'exportToIntegration', + MARK_AS_EXPORTED: 'markAsExported', + }, }, NEXT_STEP: { FINISHED: 'Finished!', @@ -1205,6 +1209,8 @@ const CONST = { NOTE: 'n', }, + IMAGE_HIGH_RESOLUTION_THRESHOLD: 7000, + IMAGE_OBJECT_POSITION: { TOP: 'top', INITIAL: 'initial', @@ -1381,6 +1387,7 @@ const CONST = { SYNC: 'sync', SYNC_REIMBURSED_REPORTS: 'syncReimbursedReports', REIMBURSEMENT_ACCOUNT_ID: 'reimbursementAccountID', + ENTITY: 'entity', }, SAGE_INTACCT: { @@ -2086,8 +2093,12 @@ const CONST = { NAME_USER_FRIENDLY: { netsuite: 'NetSuite', quickbooksOnline: 'Quickbooks Online', + quickbooksDesktop: 'Quickbooks Desktop', xero: 'Xero', intacct: 'Sage Intacct', + financialForce: 'FinancialForce', + billCom: 'Bill.com', + zenefits: 'Zenefits', }, SYNC_STAGE_NAME: { STARTING_IMPORT_QBO: 'startingImportQBO', @@ -2394,7 +2405,7 @@ const CONST = { this.ACCOUNT_ID.REWARDS, this.ACCOUNT_ID.STUDENT_AMBASSADOR, this.ACCOUNT_ID.SVFG, - ]; + ].filter((id) => id !== -1); }, // Emails that profile view is prohibited @@ -2453,6 +2464,7 @@ const CONST = { SETTINGS: 'settings', LEAVE_ROOM: 'leaveRoom', PRIVATE_NOTES: 'privateNotes', + EXPORT: 'export', DELETE: 'delete', MARK_AS_INCOMPLETE: 'markAsIncomplete', CANCEL_PAYMENT: 'cancelPayment', @@ -4012,6 +4024,15 @@ const CONST = { WARNING: 'warning', }, + /** + * Constants with different types for the modifiedAmount violation + */ + MODIFIED_AMOUNT_VIOLATION_DATA: { + DISTANCE: 'distance', + CARD: 'card', + SMARTSCAN: 'smartscan', + }, + /** * Constants for types of violation names. * Defined here because they need to be referenced by the type system to generate the diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 6151f983e8d0..f9fd379d94ce 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -3,12 +3,13 @@ import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import type {NativeEventSubscription} from 'react-native'; import {AppState, Linking, NativeModules} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import Onyx, {withOnyx} from 'react-native-onyx'; +import Onyx, {useOnyx, withOnyx} from 'react-native-onyx'; import ConfirmModal from './components/ConfirmModal'; import DeeplinkWrapper from './components/DeeplinkWrapper'; import EmojiPicker from './components/EmojiPicker/EmojiPicker'; import FocusModeNotification from './components/FocusModeNotification'; import GrowlNotification from './components/GrowlNotification'; +import RequireTwoFactorAuthenticationModal from './components/RequireTwoFactorAuthenticationModal'; import AppleAuthWrapper from './components/SignInButtons/AppleAuthWrapper'; import SplashScreenHider from './components/SplashScreenHider'; import UpdateAppModal from './components/UpdateAppModal'; @@ -37,6 +38,7 @@ import ONYXKEYS from './ONYXKEYS'; import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu'; import * as ReportActionContextMenu from './pages/home/report/ContextMenu/ReportActionContextMenu'; import type {Route} from './ROUTES'; +import ROUTES from './ROUTES'; import type {ScreenShareRequest, Session} from './types/onyx'; Onyx.registerLogger(({level, message}) => { @@ -101,6 +103,16 @@ function Expensify({ const [isSplashHidden, setIsSplashHidden] = useState(false); const [hasAttemptedToOpenPublicRoom, setAttemptedToOpenPublicRoom] = useState(false); const {translate} = useLocalize(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [shouldShowRequire2FAModal, setShouldShowRequire2FAModal] = useState(false); + + useEffect(() => { + if (!account?.needsTwoFactorAuthSetup || account.requiresTwoFactorAuth) { + return; + } + setShouldShowRequire2FAModal(true); + }, [account?.needsTwoFactorAuthSetup, account?.requiresTwoFactorAuth]); + const [initialUrl, setInitialUrl] = useState(null); useEffect(() => { @@ -253,6 +265,16 @@ function Expensify({ /> ) : null} {focusModeNotification ? : null} + {shouldShowRequire2FAModal ? ( + { + setShouldShowRequire2FAModal(false); + Navigation.navigate(ROUTES.SETTINGS_2FA.getRoute(ROUTES.HOME)); + }} + isVisible + description={translate('twoFactorAuth.twoFactorAuthIsRequiredForAdminsDescription')} + /> + ) : null} )} diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b06b05dac7e1..c71990212334 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -347,6 +347,9 @@ const ONYXKEYS = { /** Indicates whether we should store logs or not */ SHOULD_STORE_LOGS: 'shouldStoreLogs', + /** Indicates whether we should mask fragile user data while exporting onyx state or not */ + SHOULD_MASK_ONYX_STATE: 'shouldMaskOnyxState', + /** Stores new group chat draft */ NEW_GROUP_CHAT_DRAFT: 'newGroupChatDraft', @@ -373,6 +376,9 @@ const ONYXKEYS = { /** Stores info during review duplicates flow */ REVIEW_DUPLICATES: 'reviewDuplicates', + /** Stores the last export method for policy */ + LAST_EXPORT_METHOD: 'lastExportMethod', + /** Stores the information about the state of issuing a new card */ ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard', @@ -745,6 +751,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION]: boolean; [ONYXKEYS.FOCUS_MODE_NOTIFICATION]: boolean; [ONYXKEYS.NVP_LAST_PAYMENT_METHOD]: OnyxTypes.LastPaymentMethod; + [ONYXKEYS.LAST_EXPORT_METHOD]: OnyxTypes.LastExportMethod; [ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoint[]; [ONYXKEYS.NVP_INTRO_SELECTED]: OnyxTypes.IntroSelected; [ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES]: OnyxTypes.LastSelectedDistanceRates; @@ -808,6 +815,7 @@ type OnyxValuesMapping = { [ONYXKEYS.PLAID_CURRENT_EVENT]: string; [ONYXKEYS.LOGS]: OnyxTypes.CapturedLogs; [ONYXKEYS.SHOULD_STORE_LOGS]: boolean; + [ONYXKEYS.SHOULD_MASK_ONYX_STATE]: boolean; [ONYXKEYS.CACHED_PDF_PATHS]: Record; [ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS]: Record; [ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE]: OnyxTypes.QuickAction; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 1e99e2132203..f13724bf4322 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3,7 +3,7 @@ import type CONST from './CONST'; import type {IOUAction, IOUType} from './CONST'; import type {IOURequestType} from './libs/actions/IOU'; import type {AuthScreensParamList} from './libs/Navigation/types'; -import type {SageIntacctMappingName} from './types/onyx/Policy'; +import type {ConnectionName, SageIntacctMappingName} from './types/onyx/Policy'; import type {SearchQuery} from './types/onyx/SearchResults'; import type AssertTypesNotEqual from './types/utils/AssertTypesNotEqual'; @@ -198,7 +198,8 @@ const ROUTES = { }, SETTINGS_2FA: { route: 'settings/security/two-factor-auth', - getRoute: (backTo?: string) => getUrlWithBackToParam('settings/security/two-factor-auth', backTo), + getRoute: (backTo?: string, forwardTo?: string) => + getUrlWithBackToParam(forwardTo ? `settings/security/two-factor-auth?forwardTo=${encodeURIComponent(forwardTo)}` : 'settings/security/two-factor-auth', backTo), }, SETTINGS_STATUS: 'settings/profile/status', @@ -286,6 +287,10 @@ const ROUTES = { route: 'r/:reportID/details', getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/details`, backTo), }, + REPORT_WITH_ID_DETAILS_EXPORT: { + route: 'r/:reportID/details/export/:connectionName', + getRoute: (reportID: string, connectionName: ConnectionName) => `r/${reportID}/details/export/${connectionName}` as const, + }, REPORT_SETTINGS: { route: 'r/:reportID/settings', getRoute: (reportID: string) => `r/${reportID}/settings` as const, @@ -847,6 +852,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/expensify-card', getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card` as const, }, + WORKSPACE_EXPENSIFY_CARD_DETAILS: { + route: 'settings/workspaces/:policyID/expensify-card/:cardID', + getRoute: (policyID: string, cardID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/expensify-card/${cardID}`, backTo), + }, WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: { route: 'settings/workspaces/:policyID/expensify-card/issue-new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card/issue-new` as const, @@ -1029,6 +1038,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/netsuite/subsidiary-selector', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/subsidiary-selector` as const, }, + POLICY_ACCOUNTING_NETSUITE_EXISTING_CONNECTIONS: { + route: 'settings/workspaces/:policyID/accounting/netsuite/existing-connections', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/existing-connections` as const, + }, POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT: { route: 'settings/workspaces/:policyID/accounting/netsuite/token-input', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/token-input` as const, @@ -1175,6 +1188,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/sage-intacct/existing-connections', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/existing-connections` as const, }, + POLICY_ACCOUNTING_SAGE_INTACCT_ENTITY: { + route: 'settings/workspaces/:policyID/accounting/sage-intacct/entity', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/entity` as const, + }, POLICY_ACCOUNTING_SAGE_INTACCT_IMPORT: { route: 'settings/workspaces/:policyID/accounting/sage-intacct/import', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/import` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 8a71030dff44..3a0bb2248303 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -147,6 +147,7 @@ const SCREENS = { SEARCH_REPORT: 'SearchReport', SETTINGS_CATEGORIES: 'SettingsCategories', RESTRICTED_ACTION: 'RestrictedAction', + REPORT_EXPORT: 'Report_Export', }, ONBOARDING_MODAL: { ONBOARDING: 'Onboarding', @@ -240,6 +241,7 @@ const SCREENS = { REPORT_DETAILS: { ROOT: 'Report_Details_Root', SHARE_CODE: 'Report_Details_Share_Code', + EXPORT: 'Report_Details_Export', }, WORKSPACE: { @@ -288,6 +290,7 @@ const SCREENS = { NETSUITE_IMPORT_CUSTOM_SEGMENT_ADD: 'Policy_Accounting_NetSuite_Import_Custom_Segment_Add', NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS: 'Policy_Accounting_NetSuite_Import_CustomersOrProjects', NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT: 'Policy_Accounting_NetSuite_Import_CustomersOrProjects_Select', + NETSUITE_REUSE_EXISTING_CONNECTIONS: 'Policy_Accounting_NetSuite_Reuse_Existing_Connections', NETSUITE_TOKEN_INPUT: 'Policy_Accounting_NetSuite_Token_Input', NETSUITE_SUBSIDIARY_SELECTOR: 'Policy_Accounting_NetSuite_Subsidiary_Selector', NETSUITE_IMPORT: 'Policy_Accounting_NetSuite_Import', @@ -315,6 +318,7 @@ const SCREENS = { SAGE_INTACCT_PREREQUISITES: 'Policy_Accounting_Sage_Intacct_Prerequisites', ENTER_SAGE_INTACCT_CREDENTIALS: 'Policy_Enter_Sage_Intacct_Credentials', EXISTING_SAGE_INTACCT_CONNECTIONS: 'Policy_Existing_Sage_Intacct_Connections', + SAGE_INTACCT_ENTITY: 'Policy_Sage_Intacct_Entity', SAGE_INTACCT_IMPORT: 'Policy_Accounting_Sage_Intacct_Import', SAGE_INTACCT_TOGGLE_MAPPING: 'Policy_Accounting_Sage_Intacct_Toggle_Mapping', SAGE_INTACCT_MAPPING_TYPE: 'Policy_Accounting_Sage_Intacct_Mapping_Type', @@ -341,6 +345,7 @@ const SCREENS = { RATE_AND_UNIT_RATE: 'Workspace_RateAndUnit_Rate', RATE_AND_UNIT_UNIT: 'Workspace_RateAndUnit_Unit', EXPENSIFY_CARD: 'Workspace_ExpensifyCard', + EXPENSIFY_CARD_DETAILS: 'Workspace_ExpensifyCard_Details', EXPENSIFY_CARD_ISSUE_NEW: 'Workspace_ExpensifyCard_New', EXPENSIFY_CARD_BANK_ACCOUNT: 'Workspace_ExpensifyCard_BankAccount', BILLS: 'Workspace_Bills', diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx index 48604ec364c7..b9aeceeb3621 100644 --- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx +++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx @@ -41,7 +41,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow return ( - {({anchor, report, action, checkIfContextMenuActive}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive}) => ( { @@ -53,7 +53,9 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow }} onPressIn={onPressIn} onPressOut={onPressOut} - onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onLongPress={(event) => + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)) + } shouldUseHapticsOnLongPress accessibilityLabel={displayName} role={CONST.ROLE.BUTTON} diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 368347847890..b6ea09f32436 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -529,6 +529,7 @@ function AttachmentModal({ fallbackSource={fallbackSource} isUsedInAttachmentModal transactionID={transaction?.transactionID} + isUploaded={!isEmptyObject(report)} /> ) diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx index 2ec1883fd7de..a9b5dfb7feb6 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx @@ -74,6 +74,7 @@ function CarouselItem({item, onPress, isFocused, isModalHovered}: CarouselItemPr items.map((item, index) => ({source: item.source, index, isActive: index === activePageIndex})), [activePageIndex, items]); + const pagerItems = useMemo( + () => items.map((item, index) => ({source: item.source, previewSource: item.previewSource, index, isActive: index === activePageIndex})), + [activePageIndex, items], + ); const extractItemKey = useCallback( (item: Attachment, index: number) => diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index 1e9c67cf84ac..40438d47ecc7 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -52,6 +52,7 @@ function extractAttachments( if (name === 'img' && attribs.src) { const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]; const source = tryResolveUrlFromApiRoot(expensifySource || attribs.src); + const previewSource = tryResolveUrlFromApiRoot(attribs.src); if (uniqueSources.has(source)) { return; } @@ -59,6 +60,9 @@ function extractAttachments( uniqueSources.add(source); let fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`); + const width = (attribs['data-expensify-width'] && parseInt(attribs['data-expensify-width'], 10)) || undefined; + const height = (attribs['data-expensify-height'] && parseInt(attribs['data-expensify-height'], 10)) || undefined; + // Public image URLs might lack a file extension in the source URL, without an extension our // AttachmentView fails to recognize them as images and renders fallback content instead. // We apply this small hack to add an image extension and ensure AttachmentView renders the image. @@ -72,8 +76,9 @@ function extractAttachments( attachments.unshift({ reportActionID: attribs['data-id'], source, + previewSource, isAuthTokenRequired: !!expensifySource, - file: {name: fileName}, + file: {name: fileName, width, height}, isReceipt: false, hasBeenFlagged: attribs['data-flagged'] === 'true', }); diff --git a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx index e06ea3064150..ee594f66aabc 100644 --- a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx @@ -8,6 +8,7 @@ import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import type IconAsset from '@src/types/utils/IconAsset'; type DefaultAttachmentViewProps = { /** The name of the file */ @@ -21,9 +22,11 @@ type DefaultAttachmentViewProps = { /** Additional styles for the container */ containerStyles?: StyleProp; + + icon?: IconAsset; }; -function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = false, shouldShowDownloadIcon, containerStyles}: DefaultAttachmentViewProps) { +function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = false, shouldShowDownloadIcon, containerStyles, icon}: DefaultAttachmentViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -33,7 +36,7 @@ function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = fa diff --git a/src/components/Attachments/AttachmentView/HighResolutionInfo.tsx b/src/components/Attachments/AttachmentView/HighResolutionInfo.tsx new file mode 100644 index 000000000000..7ea3c83aa96f --- /dev/null +++ b/src/components/Attachments/AttachmentView/HighResolutionInfo.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + +function HighResolutionInfo({isUploaded}: {isUploaded: boolean}) { + const theme = useTheme(); + const styles = useThemeStyles(); + const stylesUtils = useStyleUtils(); + const {translate} = useLocalize(); + + return ( + + + {isUploaded ? translate('attachmentPicker.attachmentImageResized') : translate('attachmentPicker.attachmentImageTooLarge')} + + ); +} + +export default HighResolutionInfo; diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index a7409e57f846..39c25706bbfe 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -8,6 +8,7 @@ import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import DistanceEReceipt from '@components/DistanceEReceipt'; import EReceipt from '@components/EReceipt'; import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; import ScrollView from '@components/ScrollView'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useLocalize from '@hooks/useLocalize'; @@ -17,6 +18,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CachedPDFPaths from '@libs/actions/CachedPDFPaths'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import * as FileUtils from '@libs/fileDownload/FileUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import type {ColorValue} from '@styles/utils/types'; import variables from '@styles/variables'; @@ -26,6 +28,7 @@ import AttachmentViewImage from './AttachmentViewImage'; import AttachmentViewPdf from './AttachmentViewPdf'; import AttachmentViewVideo from './AttachmentViewVideo'; import DefaultAttachmentView from './DefaultAttachmentView'; +import HighResolutionInfo from './HighResolutionInfo'; type AttachmentViewOnyxProps = { transaction: OnyxEntry; @@ -70,10 +73,14 @@ type AttachmentViewProps = AttachmentViewOnyxProps & /** Whether the attachment is used as a chat attachment */ isUsedAsChatAttachment?: boolean; + + /* Flag indicating whether the attachment has been uploaded. */ + isUploaded?: boolean; }; function AttachmentView({ source, + previewSource, file, isAuthTokenRequired, onPress, @@ -92,6 +99,7 @@ function AttachmentView({ isHovered, duration, isUsedAsChatAttachment, + isUploaded = true, }: AttachmentViewProps) { const {translate} = useLocalize(); const {updateCurrentlyPlayingURL} = usePlaybackContext(); @@ -99,6 +107,7 @@ function AttachmentView({ const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [loadComplete, setLoadComplete] = useState(false); + const [isHighResolution, setIsHighResolution] = useState(false); const [hasPDFFailedToLoad, setHasPDFFailedToLoad] = useState(false); const isVideo = (typeof source === 'string' && Str.isVideo(source)) || (file?.name && Str.isVideo(file.name)); @@ -113,6 +122,12 @@ function AttachmentView({ useNetwork({onReconnect: () => setImageError(false)}); + useEffect(() => { + FileUtils.getFileResolution(file).then((resolution) => { + setIsHighResolution(FileUtils.isHighResolutionImage(resolution)); + }); + }, [file]); + // Handles case where source is a component (ex: SVG) or a number // Number may represent a SVG or an image if (typeof source === 'function' || (maybeIcon && typeof source === 'number')) { @@ -196,35 +211,61 @@ function AttachmentView({ // For this check we use both source and file.name since temporary file source is a blob // both PDFs and images will appear as images when pasted into the text field. // We also check for numeric source since this is how static images (used for preview) are represented in RN. - const isImage = typeof source === 'number' || (typeof source === 'string' && Str.isImage(source)); - if (isImage || (file?.name && Str.isImage(file.name))) { - if (imageError) { - // AttachmentViewImage can't handle icon fallbacks, so we need to handle it here - if (typeof fallbackSource === 'number' || typeof fallbackSource === 'function') { + const isSourceImage = typeof source === 'number' || (typeof source === 'string' && Str.isImage(source)); + const isFileNameImage = file?.name && Str.isImage(file.name); + const isFileImage = isSourceImage || isFileNameImage; + + if (isFileImage) { + if (imageError && (typeof fallbackSource === 'number' || typeof fallbackSource === 'function')) { + return ( + + ); + } + let imageSource = imageError && fallbackSource ? (fallbackSource as string) : (source as string); + + if (isHighResolution) { + if (!isUploaded) { return ( - + <> + + + + + ); } + imageSource = previewSource?.toString() ?? imageSource; } return ( - { - setImageError(true); - }} - /> + <> + + { + setImageError(true); + }} + /> + + {isHighResolution && } + ); } diff --git a/src/components/Attachments/types.ts b/src/components/Attachments/types.ts index 835482ca99d9..8bac4cc53af6 100644 --- a/src/components/Attachments/types.ts +++ b/src/components/Attachments/types.ts @@ -13,6 +13,9 @@ type Attachment = { /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ source: AttachmentSource; + /** URL to preview-sized attachment that is also used for the thumbnail */ + previewSource?: AttachmentSource; + /** File object can be an instance of File or Object */ file?: FileObject; diff --git a/src/components/AvatarCropModal/ImageCropView.tsx b/src/components/AvatarCropModal/ImageCropView.tsx index b7f2a64090d6..5bfb0d5f6557 100644 --- a/src/components/AvatarCropModal/ImageCropView.tsx +++ b/src/components/AvatarCropModal/ImageCropView.tsx @@ -57,6 +57,8 @@ function ImageCropView({imageUri = '', containerSize = 0, panGesture = Gesture.P // A reanimated memoized style, which updates when the image's size or scale changes. const imageStyle = useAnimatedStyle(() => { + 'worklet'; + const height = originalImageHeight.value; const width = originalImageWidth.value; const aspectRatio = height > width ? height / width : width / height; diff --git a/src/components/AvatarCropModal/Slider.tsx b/src/components/AvatarCropModal/Slider.tsx index 67aa89c9c550..bac581da25e6 100644 --- a/src/components/AvatarCropModal/Slider.tsx +++ b/src/components/AvatarCropModal/Slider.tsx @@ -30,9 +30,13 @@ function Slider({sliderValue, gestureCallbacks}: SliderProps) { // A reanimated memoized style, which tracks // a translateX shared value and updates the slider position. - const rSliderStyle = useAnimatedStyle(() => ({ - transform: [{translateX: sliderValue.value}], - })); + const rSliderStyle = useAnimatedStyle(() => { + 'worklet'; + + return { + transform: [{translateX: sliderValue.value}], + }; + }); const panGesture = Gesture.Pan() .minDistance(5) diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index d1eedd560694..baac60190ce5 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -14,6 +14,8 @@ type WorkspaceDistanceRatesBulkActionType = DeepValueOf; +type ReportExportType = DeepValueOf; + type DropdownOption = { value: TValueType; text: string; @@ -27,6 +29,7 @@ type DropdownOption = { interactive?: boolean; numberOfLinesTitle?: number; titleStyle?: ViewStyle; + shouldCloseModalOnSelect?: boolean; }; type ButtonWithDropdownMenuProps = { @@ -83,4 +86,12 @@ type ButtonWithDropdownMenuProps = { isSplitButton?: boolean; }; -export type {PaymentType, WorkspaceMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps, WorkspaceTaxRatesBulkActionType}; +export type { + PaymentType, + WorkspaceMemberBulkActionType, + WorkspaceDistanceRatesBulkActionType, + DropdownOption, + ButtonWithDropdownMenuProps, + WorkspaceTaxRatesBulkActionType, + ReportExportType, +}; diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx index 883e7261f386..a95cf9bf87d2 100644 --- a/src/components/ConfirmationPage.tsx +++ b/src/components/ConfirmationPage.tsx @@ -1,9 +1,12 @@ import React from 'react'; -import type {TextStyle} from 'react-native'; +import type {TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; +import isIllustrationLottieAnimation from '@libs/isIllustrationLottieAnimation'; +import type IconAsset from '@src/types/utils/IconAsset'; import Button from './Button'; import FixedFooter from './FixedFooter'; +import ImageSVG from './ImageSVG'; import Lottie from './Lottie'; import LottieAnimations from './LottieAnimations'; import type DotLottieAnimation from './LottieAnimations/types'; @@ -11,7 +14,7 @@ import Text from './Text'; type ConfirmationPageProps = { /** The asset to render */ - animation?: DotLottieAnimation; + illustration?: DotLottieAnimation | IconAsset; /** Heading of the confirmation page */ heading: string; @@ -31,31 +34,45 @@ type ConfirmationPageProps = { /** Additional style for the heading */ headingStyle?: TextStyle; + /** Additional style for the animation */ + illustrationStyle?: ViewStyle; + /** Additional style for the description */ descriptionStyle?: TextStyle; }; function ConfirmationPage({ - animation = LottieAnimations.Fireworks, + illustration = LottieAnimations.Fireworks, heading, description, buttonText = '', onButtonPress = () => {}, shouldShowButton = false, headingStyle, + illustrationStyle, descriptionStyle, }: ConfirmationPageProps) { const styles = useThemeStyles(); + const isLottie = isIllustrationLottieAnimation(illustration); return ( <> - + {isLottie ? ( + + ) : ( + + + + )} {heading} {description} diff --git a/src/components/ConnectToNetSuiteButton/index.tsx b/src/components/ConnectToNetSuiteButton/index.tsx index a0cd36671117..b741d584a49e 100644 --- a/src/components/ConnectToNetSuiteButton/index.tsx +++ b/src/components/ConnectToNetSuiteButton/index.tsx @@ -1,11 +1,17 @@ -import React, {useState} from 'react'; +import React, {useRef, useState} from 'react'; +import type {View} from 'react-native'; import AccountingConnectionConfirmationModal from '@components/AccountingConnectionConfirmationModal'; import Button from '@components/Button'; +import * as Expensicons from '@components/Icon/Expensicons'; +import PopoverMenu from '@components/PopoverMenu'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {removePolicyConnection} from '@libs/actions/connections'; +import {getAdminPoliciesConnectedToNetSuite} from '@libs/actions/Policy/Policy'; import Navigation from '@libs/Navigation/Navigation'; +import type {AnchorPosition} from '@styles/index'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {ConnectToNetSuiteButtonProps} from './types'; @@ -17,6 +23,30 @@ function ConnectToNetSuiteButton({policyID, shouldDisconnectIntegrationBeforeCon const [isDisconnectModalOpen, setIsDisconnectModalOpen] = useState(false); + const hasPoliciesConnectedToNetSuite = !!getAdminPoliciesConnectedToNetSuite()?.length; + const {isSmallScreenWidth} = useWindowDimensions(); + const [isReuseConnectionsPopoverOpen, setIsReuseConnectionsPopoverOpen] = useState(false); + const [reuseConnectionPopoverPosition, setReuseConnectionPopoverPosition] = useState({horizontal: 0, vertical: 0}); + const threeDotsMenuContainerRef = useRef(null); + const connectionOptions = [ + { + icon: Expensicons.LinkCopy, + text: translate('workspace.common.createNewConnection'), + onSelected: () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID)); + setIsReuseConnectionsPopoverOpen(false); + }, + }, + { + icon: Expensicons.Copy, + text: translate('workspace.common.reuseExistingConnection'), + onSelected: () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_EXISTING_CONNECTIONS.getRoute(policyID)); + setIsReuseConnectionsPopoverOpen(false); + }, + }, + ]; + return ( <>