diff --git a/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts b/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts index 96bb17a14354..e18019144e4e 100644 --- a/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts +++ b/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts @@ -18,7 +18,7 @@ function run() { GitHubUtils.octokit.actions.listWorkflowRuns({ owner: CONST.GITHUB_OWNER, repo: CONST.APP_REPO, - workflow_id: 'platformDeploy.yml', + workflow_id: 'deploy.yml', event: 'push', branch: tag, }), diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index 7bdbafc0b722..561cc980a4e5 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -12138,7 +12138,7 @@ function run() { GithubUtils_1.default.octokit.actions.listWorkflowRuns({ owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, - workflow_id: 'platformDeploy.yml', + workflow_id: 'deploy.yml', event: 'push', branch: tag, }), diff --git a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts index da946b78a056..5d5dbc7e2f29 100644 --- a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts +++ b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts @@ -35,7 +35,7 @@ async function isReleaseValidBaseForEnvironment(releaseTag: string, isProduction } /** - * Was a given platformDeploy workflow run successful on at least one platform? + * Was a given deploy workflow run successful on at least one platform? */ async function wasDeploySuccessful(runID: number) { const jobsForWorkflowRun = ( @@ -82,7 +82,7 @@ async function run() { console.log(`Looking for PRs deployed to ${deployEnv} in ${inputTag}...`); - const completedDeploys = ( + const platformDeploys = ( await GithubUtils.octokit.actions.listWorkflowRuns({ owner: github.context.repo.owner, repo: github.context.repo.repo, @@ -95,6 +95,24 @@ async function run() { // because if a build fails on even one platform, then it will have the status 'failure' .filter((workflowRun) => workflowRun.conclusion !== 'cancelled'); + const deploys = ( + await GithubUtils.octokit.actions.listWorkflowRuns({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + // eslint-disable-next-line @typescript-eslint/naming-convention + workflow_id: 'deploy.yml', + status: 'completed', + }) + ).data.workflow_runs + // Note: we filter out cancelled runs instead of looking only for success runs + // because if a build fails on even one platform, then it will have the status 'failure' + .filter((workflowRun) => workflowRun.conclusion !== 'cancelled'); + + // W've combined platformDeploy.yml and deploy.yml + // TODO: Remove this once there are successful staging and production deploys using the new deploy.yml workflow + const completedDeploys = [...deploys, ...platformDeploys]; + completedDeploys.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + // Find the most recent deploy workflow targeting the correct environment, for which at least one of the build jobs finished successfully let lastSuccessfulDeploy = completedDeploys.shift(); diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index 300cb1edc0ed..3faaeb28f548 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -11526,7 +11526,7 @@ async function isReleaseValidBaseForEnvironment(releaseTag, isProductionDeploy) return !isPrerelease; } /** - * Was a given platformDeploy workflow run successful on at least one platform? + * Was a given deploy workflow run successful on at least one platform? */ async function wasDeploySuccessful(runID) { const jobsForWorkflowRun = (await GithubUtils_1.default.octokit.actions.listJobsForWorkflowRun({ @@ -11566,7 +11566,7 @@ async function run() { const isProductionDeploy = !!(0, ActionUtils_1.getJSONInput)('IS_PRODUCTION_DEPLOY', { required: false }, false); const deployEnv = isProductionDeploy ? 'production' : 'staging'; console.log(`Looking for PRs deployed to ${deployEnv} in ${inputTag}...`); - const completedDeploys = (await GithubUtils_1.default.octokit.actions.listWorkflowRuns({ + const platformDeploys = (await GithubUtils_1.default.octokit.actions.listWorkflowRuns({ owner: github.context.repo.owner, repo: github.context.repo.repo, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -11576,6 +11576,20 @@ async function run() { // Note: we filter out cancelled runs instead of looking only for success runs // because if a build fails on even one platform, then it will have the status 'failure' .filter((workflowRun) => workflowRun.conclusion !== 'cancelled'); + const deploys = (await GithubUtils_1.default.octokit.actions.listWorkflowRuns({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + // eslint-disable-next-line @typescript-eslint/naming-convention + workflow_id: 'deploy.yml', + status: 'completed', + })).data.workflow_runs + // Note: we filter out cancelled runs instead of looking only for success runs + // because if a build fails on even one platform, then it will have the status 'failure' + .filter((workflowRun) => workflowRun.conclusion !== 'cancelled'); + // W've combined platformDeploy.yml and deploy.yml + // TODO: Remove this once there are successful staging and production deploys using the new deploy.yml workflow + const completedDeploys = [...deploys, ...platformDeploys]; + completedDeploys.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); // Find the most recent deploy workflow targeting the correct environment, for which at least one of the build jobs finished successfully let lastSuccessfulDeploy = completedDeploys.shift(); if (!lastSuccessfulDeploy) { diff --git a/.github/scripts/validateActionsAndWorkflows.sh b/.github/scripts/validateActionsAndWorkflows.sh index 07348a302f20..fadb39c88e45 100755 --- a/.github/scripts/validateActionsAndWorkflows.sh +++ b/.github/scripts/validateActionsAndWorkflows.sh @@ -45,7 +45,7 @@ for ((i=0; i < ${#WORKFLOWS[@]}; i++)); do # Skip linting e2e workflow due to bug here: https://github.com/SchemaStore/schemastore/issues/2579 if [[ "$WORKFLOW" == './workflows/e2ePerformanceTests.yml' || "$WORKFLOW" == './workflows/testBuild.yml' - || "$WORKFLOW" == './workflows/platformDeploy.yml' ]]; then + || "$WORKFLOW" == './workflows/deploy.yml' ]]; then continue fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6b1b72f1f901..f2a3f96b8f67 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,15 +4,38 @@ on: push: branches: [staging, production] +env: + SHOULD_DEPLOY_PRODUCTION: ${{ github.ref == 'refs/heads/production' }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - deployStaging: + validateActor: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/staging' + outputs: + IS_DEPLOYER: ${{ fromJSON(steps.isUserDeployer.outputs.IS_DEPLOYER) || github.actor == 'OSBotify' || github.actor == 'os-botify[bot]' }} steps: - - name: Checkout staging branch + - name: Check if user is deployer + id: isUserDeployer + run: | + if gh api /orgs/Expensify/teams/mobile-deployers/memberships/${{ github.actor }} --silent; then + echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT" + else + echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT" + fi + env: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + + createTag: + needs: validateActor + if: ${{ github.ref == 'refs/heads/staging' }} + runs-on: ubuntu-latest + steps: + - name: Checkout uses: actions/checkout@v4 with: - ref: staging token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Setup git for OSBotify @@ -23,13 +46,469 @@ jobs: OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + - name: Create and push tag + run: | + git tag "$(jq -r .version < package.json)" + git push origin --tags + + # Note: we're updating the checklist before running the deploys and assuming that it will succeed on at least one platform + deployChecklist: + name: Create or update deploy checklist + uses: ./.github/workflows/createDeployChecklist.yml + if: ${{ github.ref == 'refs/heads/staging' }} + needs: createTag + secrets: inherit + + android: + # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly + name: Build and deploy Android + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + runs-on: ubuntu-latest-xl + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'oracle' + java-version: '17' + + - name: Setup Ruby + uses: ruby/setup-ruby@v1.190.0 + with: + bundler-cache: true + + - name: Decrypt keystore + run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output my-upload-key.keystore my-upload-key.keystore.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt json key + run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - 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 + 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 }} + VERSION: ${{ env.VERSION_CODE }} + + - 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 sourcemaps artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: android-sourcemaps-artifact + path: ./android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map + + - name: Upload Android build artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: android-build-artifact + path: ./android/app/build/outputs/bundle/productionRelease/app-production-release.aab + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Warn deployers if Android production deploy failed + if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: "#DB4545", + pretext: ``, + text: `πŸ’₯ Android production deploy failed. Please manually submit ${{ env.VERSION }} in the . πŸ’₯`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + desktop: + # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly + name: Build and deploy Desktop + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + runs-on: macos-14-large + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Decrypt Developer ID Certificate + run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg + env: + DEVELOPER_ID_SECRET_PASSPHRASE: ${{ secrets.DEVELOPER_ID_SECRET_PASSPHRASE }} + + - 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 }} + 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_PRODUCTION }} + + - name: Upload desktop sourcemaps artifact + uses: actions/upload-artifact@v4 + with: + name: desktop-sourcemaps-artifact + path: ./desktop/dist/www/merged-source-map.js.map + + - name: Upload desktop build artifact + uses: actions/upload-artifact@v4 + with: + name: desktop-build-artifact + path: ./desktop-build/NewExpensify.dmg + + iOS: + # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly + name: Build and deploy iOS + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + env: + DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer + runs-on: macos-13-xlarge + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + + - name: Setup Node + id: setup-node + uses: ./.github/actions/composite/setupNode + + - name: Setup Ruby + uses: ruby/setup-ruby@v1.190.0 + with: + bundler-cache: true + + - name: Cache Pod dependencies + uses: actions/cache@v4 + id: pods-cache + with: + path: ios/Pods + key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }} + + - name: Compare Podfile.lock and Manifest.lock + id: compare-podfile-and-manifest + run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" + + - name: Install cocoapods + uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 + if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' + with: + timeout_minutes: 10 + max_attempts: 5 + command: scripts/pod-install.sh + + - name: Decrypt AppStore profile + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt AppStore Notification Service profile + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore_Notification_Service.mobileprovision NewApp_AppStore_Notification_Service.mobileprovision.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt certificate + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt App Store Connect API key + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output ios-fastlane-json-key.json ios-fastlane-json-key.json.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Set iOS version in ENV + run: echo "IOS_VERSION=$(echo '${{ env.VERSION }}' | tr '-' '.')" >> "$GITHUB_ENV" + + - name: Run Fastlane + 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: 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: Upload iOS sourcemaps artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: ios-sourcemaps-artifact + path: ./main.jsbundle.map + + - name: Upload iOS build artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: ios-build-artifact + path: /Users/runner/work/App/App/New\ Expensify.ipa + + - name: Warn deployers if iOS production deploy failed + if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: "#DB4545", + pretext: ``, + text: `πŸ’₯ iOS production deploy failed. Please manually submit ${{ env.IOS_VERSION }} in the . πŸ’₯`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + web: + # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly + name: Build and deploy Web + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + runs-on: ubuntu-latest-xl + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Setup Cloudflare CLI + run: pip3 install cloudflare==2.19.0 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Build web + run: | + if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then + npm run build + else + npm run build-staging + fi + + - 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 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: + S3_URL: s3://${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging-' || '' }}expensify-cash + + - name: Purge Cloudflare cache + run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging.' || '' }}new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache + env: + CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Verify staging deploy + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: | + sleep 5 + DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://staging.new.expensify.com/version.json | jq -r '.version')" + if [[ '${{ env.VERSION }}' != "$DOWNLOADED_VERSION" ]]; then + echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ env.VERSION }}. Something went wrong..." + exit 1 + fi + + - name: Verify production deploy + if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: | + sleep 5 + DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://new.expensify.com/version.json | jq -r '.version')" + if [[ '${{ env.VERSION }}' != "$DOWNLOADED_VERSION" ]]; then + echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ env.VERSION }}. Something went wrong..." + exit 1 + fi + + - name: Upload web sourcemaps artifact + uses: actions/upload-artifact@v4 + with: + name: web-sourcemaps-artifact + path: ./dist/merged-source-map.js.map + + - name: Compress web build .tar.gz and .zip + run: | + tar -czvf webBuild.tar.gz dist + zip -r webBuild.zip dist + + - name: Upload .tar.gz web build artifact + uses: actions/upload-artifact@v4 + with: + name: web-build-tar-gz-artifact + path: ./webBuild.tar.gz + + - name: Upload .zip web build artifact + uses: actions/upload-artifact@v4 + with: + name: web-build-zip-artifact + path: ./webBuild.zip + + postSlackMessageOnFailure: + name: Post a Slack message when any platform fails to build or deploy + runs-on: ubuntu-latest + if: ${{ failure() }} + needs: [android, desktop, iOS, web] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Post Slack message on failure + uses: ./.github/actions/composite/announceFailedWorkflowInSlack + with: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + + # Build a version of iOS and Android HybridApp if we are deploying to staging + hybridApp: + runs-on: ubuntu-latest + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) && github.ref == 'refs/heads/staging' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: 'Deploy HybridApp' + run: gh workflow run --repo Expensify/Mobile-Deploy deploy.yml -f force_build=true -f build_version="$(npm run print-version --silent)" + env: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + + checkDeploymentSuccess: + runs-on: ubuntu-latest + outputs: + IS_AT_LEAST_ONE_PLATFORM_DEPLOYED: ${{ steps.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED }} + IS_ALL_PLATFORMS_DEPLOYED: ${{ steps.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED }} + needs: [android, desktop, iOS, web] + steps: + - name: Check deployment success on at least one platform + id: checkDeploymentSuccess + run: | + isAtLeastOnePlatformDeployed="false" + isAllPlatformsDeployed="false" + if [ "${{ needs.android.result }}" == "success" ] || \ + [ "${{ needs.iOS.result }}" == "success" ] || \ + [ "${{ needs.desktop.result }}" == "success" ] || \ + [ "${{ needs.web.result }}" == "success" ]; then + isAtLeastOnePlatformDeployed="true" + fi + if [ "${{ needs.android.result }}" == "success" ] && \ + [ "${{ needs.iOS.result }}" == "success" ] && \ + [ "${{ needs.desktop.result }}" == "success" ] && \ + [ "${{ needs.web.result }}" == "success" ]; then + isAllPlatformsDeployed="true" + fi + echo "IS_AT_LEAST_ONE_PLATFORM_DEPLOYED=\"$isAtLeastOnePlatformDeployed\"" >> "$GITHUB_OUTPUT" + echo "IS_ALL_PLATFORMS_DEPLOYED=\"$isAllPlatformsDeployed\"" >> "$GITHUB_OUTPUT" + + createPrerelease: + runs-on: ubuntu-latest + if: ${{ github.ref == 'refs/heads/staging' && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} + needs: [checkDeploymentSuccess] + steps: + - name: Checkout staging branch + uses: actions/checkout@v4 + - name: Get current app version - run: echo "STAGING_VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + run: echo "STAGING_VERSION=$(jq -r .version < package.json)" >> "$GITHUB_ENV" - - name: πŸš€ Create prerelease to trigger staging deploy πŸš€ - run: gh release create ${{ env.STAGING_VERSION }} --title ${{ env.STAGING_VERSION }} --generate-notes --prerelease --target staging + - name: Download all workflow run artifacts + uses: actions/download-artifact@v4 + + - name: πŸš€ Create prerelease πŸš€ + run: | + gh release create ${{ env.STAGING_VERSION }} --title ${{ env.STAGING_VERSION }} --generate-notes --prerelease --target staging + RETRIES=0 + MAX_RETRIES=10 + until [[ $(gh release view ${{ env.STAGING_VERSION }}) || $RETRIES -ge $MAX_RETRIES ]]; do + echo "release not found, retrying $((MAX_RETRIES - RETRIES++)) times" + sleep 1 + done env: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} + + - name: Rename web and desktop sourcemaps artifacts before assets upload in order to have unique ReleaseAsset.name + run: | + mv ./desktop-sourcemaps-artifact/merged-source-map.js.map ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map + mv ./web-sourcemaps-artifact/merged-source-map.js.map ./web-sourcemaps-artifact/web-merged-source-map.js.map + + - name: Upload artifacts to GitHub Release + run: | + gh release upload ${{ env.STAGING_VERSION }} \ + ./android-sourcemaps-artifact/index.android.bundle.map#android-sourcemap-${{ env.STAGING_VERSION }} \ + ./android-build-artifact/app-production-release.aab \ + ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map#desktop-sourcemap-${{ env.STAGING_VERSION }} \ + ./desktop-build-artifact/NewExpensify.dmg \ + ./ios-sourcemaps-artifact/main.jsbundle.map#ios-sourcemap-${{ env.STAGING_VERSION }} \ + ./ios-build-artifact/New\ Expensify.ipa \ + ./web-sourcemaps-artifact/web-merged-source-map.js.map#web-sourcemap-${{ env.STAGING_VERSION }} \ + ./web-build-tar-gz-artifact/webBuild.tar.gz \ + ./web-build-zip-artifact/webBuild.zip + env: + GITHUB_TOKEN: ${{ github.token }} - name: Warn deployers if staging deploy failed if: ${{ failure() }} @@ -49,34 +528,43 @@ jobs: GITHUB_TOKEN: ${{ github.token }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - deployProduction: + finalizeRelease: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/production' + if: ${{ github.ref == 'refs/heads/production' && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} + needs: [checkDeploymentSuccess] steps: - - uses: actions/checkout@v4 - name: Checkout - with: - ref: production - token: ${{ secrets.OS_BOTIFY_TOKEN }} - - - name: Setup git for OSBotify - uses: ./.github/actions/composite/setupGitForOSBotifyApp - id: setupGitForOSBotify - with: - GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} - OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + - name: Checkout production branch + uses: actions/checkout@v4 - name: Get current app version run: echo "PRODUCTION_VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - name: πŸš€ Edit the release to be no longer a prerelease to deploy production πŸš€ + - name: Download all workflow run artifacts + uses: actions/download-artifact@v4 + + - name: Rename web and desktop sourcemaps artifacts before assets upload in order to have unique ReleaseAsset.name + run: | + mv ./desktop-sourcemaps-artifact/merged-source-map.js.map ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map + mv ./web-sourcemaps-artifact/merged-source-map.js.map ./web-sourcemaps-artifact/web-merged-source-map.js.map + + - name: Upload artifacts to GitHub Release + run: | + gh release upload ${{ env.STAGING_VERSION }} \ + ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map#desktop-sourcemap-${{ env.STAGING_VERSION }} \ + ./desktop-build-artifact/NewExpensify.dmg \ + ./web-sourcemaps-artifact/web-merged-source-map.js.map#web-sourcemap-${{ env.STAGING_VERSION }} \ + ./web-build-tar-gz-artifact/webBuild.tar.gz \ + ./web-build-zip-artifact/webBuild.zip + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: πŸš€ Edit the release to be no longer a prerelease πŸš€ run: | LATEST_RELEASE="$(gh release list --exclude-pre-releases --json tagName,isLatest --jq '.[] | select(.isLatest) | .tagName')" - gh api --method POST /repos/Expensify/App/releases/generate-notes -f "tag_name=${{ env.PRODUCTION_VERSION }}" -f "previous_tag_name=$LATEST_RELEASE" >> releaseNotes.md + gh api --method POST /repos/Expensify/App/releases/generate-notes -f "tag_name=${{ env.PRODUCTION_VERSION }}" -f "previous_tag_name=$LATEST_RELEASE" | jq -r '.body' >> releaseNotes.md gh release edit ${{ env.PRODUCTION_VERSION }} --prerelease=false --latest --notes-file releaseNotes.md env: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} - name: Warn deployers if production deploy failed if: ${{ failure() }} @@ -95,3 +583,99 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + postSlackMessageOnSuccess: + name: Post a Slack message when all platforms deploy successfully + runs-on: ubuntu-latest + if: ${{ fromJSON(needs.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED) }} + needs: [checkDeploymentSuccess, createPrerelease, finalizeRelease] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: 'Announces the deploy in the #announce Slack room' + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#announce', + attachments: [{ + color: 'good', + text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + - name: 'Announces the deploy in the #deployer Slack room' + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: 'good', + text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + - name: 'Announces a production deploy in the #expensify-open-source Slack room' + uses: 8398a7/action-slack@v3 + if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + with: + status: custom + custom_payload: | + { + channel: '#expensify-open-source', + attachments: [{ + color: 'good', + text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to production πŸŽ‰οΈ`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + postGithubComment: + name: Post a GitHub comments on all deployed PRs when platforms are done building and deploying + runs-on: ubuntu-latest + if: ${{ fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} + needs: [android, desktop, iOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Get Release Pull Request List + id: getReleasePRList + uses: ./.github/actions/javascript/getDeployPullRequestList + with: + TAG: ${{ env.VERSION }} + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + + - name: Comment on issues + uses: ./.github/actions/javascript/markPullRequestsAsDeployed + with: + PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} + IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + DEPLOY_VERSION: ${{ env.VERSION }} + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + ANDROID: ${{ needs.android.result }} + DESKTOP: ${{ needs.desktop.result }} + IOS: ${{ needs.iOS.result }} + WEB: ${{ needs.web.result }} diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml deleted file mode 100644 index bacab79998f9..000000000000 --- a/.github/workflows/platformDeploy.yml +++ /dev/null @@ -1,487 +0,0 @@ -name: Build and deploy android, desktop, iOS, and web clients - -# This workflow is run when a release or prerelease is created -on: - release: - types: [prereleased, released] - -env: - SHOULD_DEPLOY_PRODUCTION: ${{ github.event.action == 'released' }} - -concurrency: - group: ${{ github.workflow }}-${{ github.event.action }} - cancel-in-progress: true - -jobs: - validateActor: - runs-on: ubuntu-latest - outputs: - IS_DEPLOYER: ${{ fromJSON(steps.isUserDeployer.outputs.IS_DEPLOYER) || github.actor == 'OSBotify' || github.actor == 'os-botify[bot]' }} - steps: - - name: Check if user is deployer - id: isUserDeployer - run: | - if gh api /orgs/Expensify/teams/mobile-deployers/memberships/${{ github.actor }} --silent; then - echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT" - else - echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT" - fi - env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - - # Note: we're updating the checklist before running the deploys and assuming that it will succeed on at least one platform - deployChecklist: - name: Create or update deploy checklist - uses: ./.github/workflows/createDeployChecklist.yml - if: ${{ github.event.action != 'released' }} - needs: validateActor - secrets: inherit - - android: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy Android - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - runs-on: ubuntu-latest-xl - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Configure MapBox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'oracle' - java-version: '17' - - - name: Setup Ruby - uses: ruby/setup-ruby@v1.190.0 - with: - bundler-cache: true - - - name: Decrypt keystore - run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output my-upload-key.keystore my-upload-key.keystore.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt json key - run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - 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 - 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 }} - VERSION: ${{ env.VERSION_CODE }} - - - 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 sourcemaps to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map#android-sourcemap-${{ github.event.release.tag_name }} - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload Android build to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} android/app/build/outputs/bundle/productionRelease/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 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: "#DB4545", - pretext: ``, - text: `πŸ’₯ Android production deploy failed. Please manually submit ${{ github.event.release.tag_name }} in the . πŸ’₯`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - desktop: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy Desktop - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - runs-on: macos-14-large - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Decrypt Developer ID Certificate - run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg - env: - DEVELOPER_ID_SECRET_PASSPHRASE: ${{ secrets.DEVELOPER_ID_SECRET_PASSPHRASE }} - - - 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 }} - 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_PRODUCTION }} - - - name: Upload desktop sourcemaps to GitHub Release - run: gh release upload ${{ github.event.release.tag_name }} desktop/dist/www/merged-source-map.js.map#desktop-sourcemap-${{ github.event.release.tag_name }} --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload desktop build to GitHub Release - run: gh release upload ${{ github.event.release.tag_name }} desktop-build/NewExpensify.dmg --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - iOS: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy iOS - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - env: - DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer - runs-on: macos-13-xlarge - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Configure MapBox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - - name: Setup Node - id: setup-node - uses: ./.github/actions/composite/setupNode - - - name: Setup Ruby - uses: ruby/setup-ruby@v1.190.0 - with: - bundler-cache: true - - - name: Cache Pod dependencies - uses: actions/cache@v4 - id: pods-cache - with: - path: ios/Pods - key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }} - - - name: Compare Podfile.lock and Manifest.lock - id: compare-podfile-and-manifest - run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" - - - name: Install cocoapods - uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 - if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' - with: - timeout_minutes: 10 - max_attempts: 5 - command: scripts/pod-install.sh - - - name: Decrypt AppStore profile - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt AppStore Notification Service profile - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore_Notification_Service.mobileprovision NewApp_AppStore_Notification_Service.mobileprovision.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt certificate - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt App Store Connect API key - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output ios-fastlane-json-key.json ios-fastlane-json-key.json.gpg - 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 - 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: 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: Upload iOS sourcemaps to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} main.jsbundle.map#ios-sourcemap-${{ github.event.release.tag_name }} - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload iOS build to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} /Users/runner/work/App/App/New\ Expensify.ipa - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Warn deployers if iOS production deploy failed - if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: "#DB4545", - pretext: ``, - text: `πŸ’₯ iOS production deploy failed. Please manually submit ${{ env.IOS_VERSION }} in the . πŸ’₯`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - web: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy Web - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - runs-on: ubuntu-latest-xl - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Setup Cloudflare CLI - run: pip3 install cloudflare==2.19.0 - - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-1 - - - name: Build web - run: | - if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then - npm run build - else - npm run build-staging - fi - - - 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 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: - S3_URL: s3://${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging-' || '' }}expensify-cash - - - name: Purge Cloudflare cache - run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging.' || '' }}new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache - env: - CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} - - - name: Verify staging deploy - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: | - sleep 5 - DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://staging.new.expensify.com/version.json | jq -r '.version')" - if [[ '${{ github.event.release.tag_name }}' != "$DOWNLOADED_VERSION" ]]; then - echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ github.event.release.tag_name }}. Something went wrong..." - exit 1 - fi - - - name: Verify production deploy - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: | - sleep 5 - DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://new.expensify.com/version.json | jq -r '.version')" - if [[ '${{ github.event.release.tag_name }}' != "$DOWNLOADED_VERSION" ]]; then - echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ github.event.release.tag_name }}. Something went wrong..." - exit 1 - fi - - - name: Upload web sourcemaps to GitHub Release - run: gh release upload ${{ github.event.release.tag_name }} dist/merged-source-map.js.map#web-sourcemap-${{ github.event.release.tag_name }} --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload web build to GitHub Release - run: | - tar -czvf webBuild.tar.gz dist - zip -r webBuild.zip dist - gh release upload ${{ github.event.release.tag_name }} webBuild.tar.gz webBuild.zip --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - postSlackMessageOnFailure: - name: Post a Slack message when any platform fails to build or deploy - runs-on: ubuntu-latest - if: ${{ failure() }} - needs: [android, desktop, iOS, web] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Post Slack message on failure - uses: ./.github/actions/composite/announceFailedWorkflowInSlack - with: - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} - - # Build a version of iOS and Android HybridApp if we are deploying to staging - hybridApp: - runs-on: ubuntu-latest - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) && github.event.action != 'released' }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: 'Deploy HybridApp' - run: gh workflow run --repo Expensify/Mobile-Deploy deploy.yml -f force_build=true -f build_version="$(npm run print-version --silent)" - env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - - postSlackMessageOnSuccess: - name: Post a Slack message when all platforms deploy successfully - runs-on: ubuntu-latest - if: ${{ success() }} - needs: [android, desktop, iOS, web] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set version - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - - name: 'Announces the deploy in the #announce Slack room' - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#announce', - attachments: [{ - color: 'good', - text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - - name: 'Announces the deploy in the #deployer Slack room' - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: 'good', - text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - - name: 'Announces a production deploy in the #expensify-open-source Slack room' - uses: 8398a7/action-slack@v3 - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - with: - status: custom - custom_payload: | - { - channel: '#expensify-open-source', - attachments: [{ - color: 'good', - text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to production πŸŽ‰οΈ`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - postGithubComment: - name: Post a GitHub comment when platforms are done building and deploying - runs-on: ubuntu-latest - if: ${{ !cancelled() }} - needs: [android, desktop, iOS, web] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Set version - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - - name: Get Release Pull Request List - id: getReleasePRList - uses: ./.github/actions/javascript/getDeployPullRequestList - with: - TAG: ${{ env.VERSION }} - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - - - name: Comment on issues - uses: ./.github/actions/javascript/markPullRequestsAsDeployed - with: - PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} - IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - DEPLOY_VERSION: ${{ env.VERSION }} - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - ANDROID: ${{ needs.android.result }} - DESKTOP: ${{ needs.desktop.result }} - IOS: ${{ needs.iOS.result }} - WEB: ${{ needs.web.result }} diff --git a/android/app/build.gradle b/android/app/build.gradle index 4b1fb7ae40cf..3604cf25d1c1 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 1009002906 - versionName "9.0.29-6" + versionCode 1009003015 + versionName "9.0.30-15" // 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/emojis/common.ts b/assets/emojis/common.ts index 5162a71367b2..1039249f0ac8 100644 --- a/assets/emojis/common.ts +++ b/assets/emojis/common.ts @@ -774,11 +774,6 @@ const emojis: PickerEmojis = [ code: '🀞', types: ['🀞🏿', '🀞🏾', '🀞🏽', '🀞🏼', '🀞🏻'], }, - { - name: 'hand_with_index_finger_and_thumb_crossed', - code: '🫰', - types: ['🫰🏿', '🫰🏾', '🫰🏽', '🫰🏼', '🫰🏻'], - }, { name: 'love_you_gesture', code: '🀟', @@ -844,6 +839,11 @@ const emojis: PickerEmojis = [ code: 'πŸ‘Ž', types: ['πŸ‘ŽπŸΏ', 'πŸ‘ŽπŸΎ', 'πŸ‘ŽπŸ½', 'πŸ‘ŽπŸΌ', 'πŸ‘ŽπŸ»'], }, + { + name: 'hand_with_index_finger_and_thumb_crossed', + code: '🫰', + types: ['🫰🏿', '🫰🏾', '🫰🏽', '🫰🏼', '🫰🏻'], + }, { name: 'fist_raised', code: '✊', diff --git a/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg b/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg new file mode 100644 index 000000000000..9c0711fcaedc --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md deleted file mode 100644 index b2cfbf833e13..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: Reimburse reports, invoices, and bills -description: Use direct deposit or indirect reimbursement to pay reports, invoices, and bills ---- -
- -Once a report, invoice, or bill has been submitted and approved for reimbursement, you can reimburse the expenses using direct deposit or an indirect reimbursement option outside of Expensify (like cash, a check, or a third-party payment processor). - -# Pay with direct deposit - -{% include info.html %} -Before a report can be reimbursed with direct deposit, the employee or vendor receiving the reimbursement must connect their personal U.S. bank account, and the reimburser must connect a verified business bank account. - -Direct deposit is available for U.S. and global reimbursements. It is not available for Australian bank accounts. For Australian accounts, review the process for reimbursing Australian expenses. -{% include end-info.html %} - -1. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab. -2. Click the **Reimburse** (for reports) or **Pay** (for bills and invoices) dropdown and select **Via Direct Deposit (ACH)**. -3. Confirm that the correct VBA is selected or use the dropdown menu to select a different one. -4. Click **Accept Terms & Pay**. - -The reimbursement is now queued in the daily batch. - -# Pay with indirect reimbursement - -When payments are submitted through Expensify, the report is automatically labeled as Reimbursed after it has been paid. However, if you are reimbursing reports via paper check, payroll, or any other method that takes place outside of Expensify, you’ll want to manually mark the bill as paid in Expensify to track the payment history. - -To label a report as Reimbursed after sending a payment outside of Expensify, - -1. Pay the report, invoice, or bill outside of Expensify. -2. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab. -3. Click **Reimburse**. -4. Select **I’ll do it manually - just mark it as reimbursed**. This changes the report status to Reimbursed. - -Once the recipient has received the payment, the submitter can return to the report and click **Confirm** at the top of the report. This will change the report status to Reimbursed: CONFIRMED. - -{% include faq-begin.md %} - -**Is there a maximum total report total?** - -Expensify cannot process a reimbursement for any single report over $20,000. If you have a report with expenses exceeding $20,000 we recommend splitting the expenses into multiple reports. - -**Why is my account locked?** - -When you reimburse a report, you authorize Expensify to withdraw the funds from your account and send them to the person requesting reimbursement. If your bank rejects Expensify’s withdrawal request, your verified bank account is locked until the issue is resolved. - -Withdrawal requests can be rejected if the bank account has not been enabled for direct debit or due to insufficient funds. If you need to enable direct debits from your verified bank account, your bank will require the following details: -- The ACH CompanyIDs: 1270239450 and 4270239450 -- The ACH Originator Name: Expensify - -Once resolved, you can request to unlock the bank account by completing the following steps: - -1. Hover over **Settings**, then click **Account**. -2. Click the **Payments** tab. -3. Click **Bank Accounts**. -4. Next to the bank account, click **Fix**. - -Our support team will review and process the request within 4-5 business days. - -**How are bills and invoices processed in Expensify?** - -Here is the process a vendor or supplier bill goes through from receipt to payment: - -1. A vendor or supplier bill is received in Expensify. -2. Automatically, the document is SmartScanned and a bill is created for the primary domain contact. The bill will appear under the Reports tab on their default group policy. -3. When the bill is ready for processing, it is submitted and follows the primary domain contact’s approval workflow until the bill has been fully approved. -4. The final approver pays the bill from their Expensify account using one of the methods outlined in the article above. -5. If the workspace is connected to an accounting integration, the bill is automatically coded with the relevant imported GL codes and can be exported back to the accounting software. - -**When a vendor or supplier bill is sent to Expensify, who receives it?** - -Bills are sent to the primary contact for the domain. They’ll see a notification from Concierge on their Home page, and they’ll also receive an email. - -**How can I share access to bills?** - -By default, only the primary contact for the domain can view and pay the bill. However, you can allow someone else to view or pay bills. - -- **To allow someone to view a bill**: The primary contact can manually share the bill with others to allow them to view it. - 1. Click the **Reports** tab. - 2. Click the report. - 3. Click **Details** in the top right. - 4. Click the **Add Person** icon. - 5. Enter the email address or phone number of the person you will share the report with. - 6. Enter a message, if desired. - 7. Click **Share Report**. - -- **To allow someone to pay bills**: The primary domain contact can allow others to pay bills on their behalf by [assigning those individuals as Copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot). - -**Is Bill Pay supported internationally?** - -Payments are currently only supported for users paying in United States Dollars (USD). - -**What’s the difference between a bill and an invoice?** - -- A **bill** is a payable that represents an amount owed to a payee (usually a vendor or supplier), and it is usually created from a vendor invoice. -- An **invoice** is a receivable that indicates an amount owed to you by someone else. - -**Who can reimburse reports?** - -Only a Workspace Admin who has added a verified business bank account to their Expensify account can reimburse employee reports. - -**Why can’t I trigger direct ACH reimbursements in bulk?** - -Expensify does not offer bulk reimbursement, but you can set up automatic reimbursement to automatically reimburse approved reports via ACH that do not exceed the threshold that you define. - -{% include faq-end.md %} - -
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md new file mode 100644 index 000000000000..afe366fb1dbe --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md @@ -0,0 +1,95 @@ +--- +title: Reimburse Reports +description: +--- +
+ +Once a report is submitted and approved, you can reimburse the expenses directly via direct deposit or global reimbursement, use an indirect reimbursement method (such as a third-party payment processor), or mark the report as reimbursed outside of Expensify (if your organization bundles reimbursements in payroll, for instance). + +## Direct Deposit - USD + +Before a report can be reimbursed via direct deposit: +- The reimburser must [connect a verified business bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account) +- The recipient must [connect a personal bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account) + +To reimburse a report via direct deposit (USD): +1. Open the report. +2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**. +3. Confirm that the correct bank account is listed in the dropdown menu. +4. Click **Accept Terms & Pay**. + +If the reimbursement is less than $200, it will typically be deposited into the employee's bank account immediately. If the reimbursement is more than $200, the deposit will be processed within one to five business days. + +## Direct Deposit - Global Reimbursement +Before a report can be reimbursed via global reimbursement: +- A workspace admin must [set up global reimbursements](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements) +- Employees must [connect a deposit account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account) + +To reimburse a report via global reimbursement: +1. Open the report. +2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**. +3. Confirm that the correct bank account is listed in the dropdown menu. +4. Click **Accept Terms & Pay**. + +The reimbursement should be processed within five business days. If the payment hasn't been processed within that timeframe, reach out to Expensify Support for assistance. + +## Indirect Reimbursement +If you are reimbursing reports outside of Expensify via paper check or payroll, you’ll want to manually mark the report as paid to track the payment history. + +To label a report as Reimbursed after sending a payment outside of Expensify: +1. Open the report +2. Click **Reimburse**. +3. Select **I’ll do it manually - just mark it as reimbursed**. This changes the report status to Reimbursed. + +Once the recipient has received the payment, the submitter can return to the report and click **Confirm**. This will change the report status to **`Reimbursed: CONFIRMED`**. + +### Reimburse a report via a third-party payment provider + +If both the reimburser and the payment recipient have Venmo accounts, you can [connect them directly to Expensify](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments) to send and receive reimbursements. + +### Reimburse a report via ABA batch file +Workspace Admins can reimburse AUD expense reports by downloading an ABA file containing the accounts needing payment and uploading the file to the bank. This can be done for a single report or for a batch of payments. + +More information on reimbursing reports via ABA batch file can be found **[here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Australian-Reports)**. + +{% include faq-begin.md %} + +## Is there a maximum report total? + +Expensify cannot process a reimbursement for any single report over $20,000. If you have a report with expenses exceeding $20,000 we recommend splitting the expenses into multiple reports. + +## Why is my business bank account locked? + +When you reimburse a report, you authorize Expensify to withdraw the funds from your account and send them to the person requesting reimbursement. If your bank rejects Expensify’s withdrawal request, your verified bank account is locked until the issue is resolved. + +Withdrawal requests can be rejected if the bank account has not been enabled for direct debit or due to insufficient funds. If you need to enable direct debits from your verified bank account, your bank will require the following details: +- The ACH CompanyIDs: 1270239450 and 4270239450 +- The ACH Originator Name: Expensify + +Once resolved, you can request to unlock the bank account by completing the following steps: +1. Hover over **Settings**, then click **Account**. +2. Click the **Payments** tab. +3. Click **Bank Accounts**. +4. Next to the bank account, click **Fix**. + +Our support team will review and process the request within 4-5 business days. + +## Who can reimburse reports? + +Only a Workspace Admin who has added a verified business bank account connected to their Expensify account can reimburse employee reports. + +## How can I add another employee as a reimburser? + +You can give another employee access to reimburse reports by doing the following: +1. If they're not already a workspace admin, add them as one under **Settings > Workspaces > [Workspace Name] > Members**. +2. Share the business bank account with them by heading to **Settings > Account > Payments** and clicking **Share**. +3. The new reimburser will need to validate the shared bank connection by entering the test deposits that Expensify sends to the bank account. +4. Once validated, the employee will have access to reimburse reports. You can make them the default reimburser for all reports submitted on a specific workspace by selecting them from the dropdown menu under **Settings > Workspaces > [Workspace Name] > Reimbursements > Reimburser**. + +## Why can’t I trigger direct ACH reimbursements in bulk? + +Expensify does not offer bulk reimbursement, but you can automate reimbursements by setting a threshold amount under **Settings > Workspaces > [Workspace Name] > Reimbursement**. After setting a threshold amount, an employee's reimbursement is triggered once a report is **Final Approved**. If the total of a report is more than the threshold amount, the reimbursement will need to be manually triggered. + +{% include faq-end.md %} + +
diff --git a/docs/redirects.csv b/docs/redirects.csv index 480fd4220bd4..256e7f370575 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -569,3 +569,4 @@ https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2 https://community.expensify.com/discussion/5654/deep-dive-using-expense-rules-to-vendor-match-when-exporting-to-an-accounting-package/p1?new=1,https://help.expensify.com/articles/expensify-classic/connections/xero/Xero-Troubleshooting https://help.expensify.com/articles/expensify-classic/spending-insights/(https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates),https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-notifications,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-Notifications +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index f676f248fb8c..27d35cd77d3d 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.29 + 9.0.30 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.29.6 + 9.0.30.15 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 5eb312548f35..8e299efa41e9 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.29 + 9.0.30 CFBundleSignature ???? CFBundleVersion - 9.0.29.6 + 9.0.30.15 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index c13e6f18dc1e..a037eddf9ebf 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.29 + 9.0.30 CFBundleVersion - 9.0.29.6 + 9.0.30.15 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index e46eb0494ea7..d04006e08377 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.29-6", + "version": "9.0.30-15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.29-6", + "version": "9.0.30-15", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -96,7 +96,7 @@ "react-native-google-places-autocomplete": "2.5.6", "react-native-haptic-feedback": "^2.2.0", "react-native-image-picker": "^7.0.3", - "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#93399c6410de32966eb57085936ef6951398c2c3", + "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", "react-native-keyboard-controller": "^1.12.2", "react-native-launch-arguments": "^4.0.2", @@ -232,6 +232,7 @@ "eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0", "html-webpack-plugin": "^5.5.0", "http-server": "^14.1.1", + "husky": "^9.1.5", "jest": "29.4.1", "jest-circus": "29.4.1", "jest-cli": "29.4.1", @@ -28727,6 +28728,21 @@ "ms": "^2.0.0" } }, + "node_modules/husky": { + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.5.tgz", + "integrity": "sha512-rowAVRUBfI0b4+niA4SJMhfQwc107VLkBUgEYYAOQAbqDCnra1nYh83hF/MDmhYs9t9n1E3DuKOrs2LYNC+0Ag==", + "dev": true, + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/hyperdyperid": { "version": "1.2.0", "dev": true, @@ -37314,8 +37330,8 @@ }, "node_modules/react-native-image-size": { "version": "1.1.3", - "resolved": "git+ssh://git@github.com/Expensify/react-native-image-size.git#93399c6410de32966eb57085936ef6951398c2c3", - "integrity": "sha512-hR38DhM3ewEv5VPhyCAbrhgWWlA1Hyys69BdUFkUes2wgiZc2ARVaXoLKuvzYT3g9fNYLwijylaSEs3juDkPKg==" + "resolved": "git+ssh://git@github.com/Expensify/react-native-image-size.git#cb392140db4953a283590d7cf93b4d0461baa2a9", + "integrity": "sha512-kF/8fGsKoOnjPZceipRUaM9Xg9a/aKXU2Vm5eHYEKHrRt8FP39oCbaELPTb/vUKRTu1HmEGffDFzRT02BcdzYQ==" }, "node_modules/react-native-key-command": { "version": "1.0.8", diff --git a/package.json b/package.json index b36985b72631..7da6a74dcbb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.29-6", + "version": "9.0.30-15", "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.", @@ -153,7 +153,7 @@ "react-native-google-places-autocomplete": "2.5.6", "react-native-haptic-feedback": "^2.2.0", "react-native-image-picker": "^7.0.3", - "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#93399c6410de32966eb57085936ef6951398c2c3", + "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", "react-native-keyboard-controller": "^1.12.2", "react-native-launch-arguments": "^4.0.2", @@ -289,6 +289,7 @@ "eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0", "html-webpack-plugin": "^5.5.0", "http-server": "^14.1.1", + "husky": "^9.1.5", "jest": "29.4.1", "jest-circus": "29.4.1", "jest-cli": "29.4.1", diff --git a/src/CONFIG.ts b/src/CONFIG.ts index a1a72b86fadd..047d4dc823fd 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -97,5 +97,5 @@ export default { }, GCP_GEOLOCATION_API_KEY: googleGeolocationAPIKey, // to read more about StrictMode see: contributingGuides/STRICT_MODE.md - USE_REACT_STRICT_MODE_IN_DEV: true, + USE_REACT_STRICT_MODE_IN_DEV: false, } as const; diff --git a/src/CONST.ts b/src/CONST.ts index 15b3dab82cde..d0695b1e285f 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -4,6 +4,7 @@ import dateSubtract from 'date-fns/sub'; import Config from 'react-native-config'; import * as KeyCommand from 'react-native-key-command'; import type {ValueOf} from 'type-fest'; +import type {Video} from './libs/actions/Report'; import BankAccount from './libs/models/BankAccount'; import * as Url from './libs/Url'; import SCREENS from './SCREENS'; @@ -64,16 +65,91 @@ const chatTypes = { // Explicit type annotation is required const cardActiveStates: number[] = [2, 3, 4, 7]; -const onboardingChoices = { +const selectableOnboardingChoices = { PERSONAL_SPEND: 'newDotPersonalSpend', MANAGE_TEAM: 'newDotManageTeam', EMPLOYER: 'newDotEmployer', CHAT_SPLIT: 'newDotSplitChat', LOOKING_AROUND: 'newDotLookingAround', +} as const; + +const backendOnboardingChoices = { + SUBMIT: 'newDotSubmit', +} as const; + +const onboardingChoices = { + ...selectableOnboardingChoices, + ...backendOnboardingChoices, +} as const; + +const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { + message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', + video: { + url: `${CLOUDFRONT_URL}/videos/guided-setup-get-paid-back-v2.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-get-paid-back.jpg`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'submitExpense', + autoCompleted: false, + title: 'Submit an expense', + description: + '*Submit an expense* by entering an amount or scanning a receipt.\n' + + '\n' + + 'Here’s how to submit an expense:\n' + + '\n' + + '1. Click the green *+* button.\n' + + '2. Choose *Submit expense*.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Add your reimburser to the request.\n' + + '\n' + + 'Then, send your request and wait for that sweet β€œCha-ching!” when it’s complete.', + }, + { + type: 'enableWallet', + autoCompleted: false, + title: 'Enable your wallet', + description: + 'You’ll need to *enable your Expensify Wallet* to get paid back. Don’t worry, it’s easy!\n' + + '\n' + + 'Here’s how to set up your wallet:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click *Wallet* > *Enable wallet*.\n' + + '3. Connect your bank account.\n' + + '\n' + + 'Once that’s done, you can request money from anyone and get paid back right into your personal bank account.', + }, + ], }; type OnboardingPurposeType = ValueOf; +const onboardingInviteTypes = { + IOU: 'iou', + INVOICE: 'invoice', + CHAT: 'chat', +} as const; + +type OnboardingInviteType = ValueOf; + +type OnboardingTaskType = { + type: string; + autoCompleted: boolean; + title: string; + description: string | ((params: Partial<{adminsRoomLink: string; workspaceCategoriesLink: string; workspaceMoreFeaturesLink: string; workspaceMembersLink: string}>) => string); +}; + +type OnboardingMessageType = { + message: string; + video?: Video; + tasks: OnboardingTaskType[]; + type?: string; +}; + const CONST = { HEIC_SIGNATURES: [ '6674797068656963', // 'ftypheic' - Indicates standard HEIC file @@ -639,7 +715,7 @@ const CONST = { SAGE_INTACCT_HELP_LINK: "https://help.expensify.com/articles/expensify-classic/connections/sage-intacct/Sage-Intacct-Troubleshooting#:~:text=First%20make%20sure%20that%20you,your%20company's%20Web%20Services%20authorizations.", PRICING: `https://www.expensify.com/pricing`, - + CUSTOM_REPORT_NAME_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates', // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', OLDDOT_URLS: { @@ -804,6 +880,7 @@ const CONST = { UPDATE_AUTO_REPORTING_FREQUENCY: 'POLICYCHANGELOG_UPDATE_AUTOREPORTING_FREQUENCY', UPDATE_BUDGET: 'POLICYCHANGELOG_UPDATE_BUDGET', UPDATE_CATEGORY: 'POLICYCHANGELOG_UPDATE_CATEGORY', + UPDATE_CATEGORIES: 'POLICYCHANGELOG_UPDATE_CATEGORIES', UPDATE_CURRENCY: 'POLICYCHANGELOG_UPDATE_CURRENCY', UPDATE_CUSTOM_UNIT: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT', UPDATE_CUSTOM_UNIT_RATE: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT_RATE', @@ -1262,6 +1339,7 @@ const CONST = { ATTACHMENT_TYPE: { REPORT: 'r', NOTE: 'n', + SEARCH: 's', }, IMAGE_HIGH_RESOLUTION_THRESHOLD: 7000, @@ -1362,21 +1440,25 @@ const CONST = { }, QUICKBOOKS_ONLINE: 'quickbooksOnline', - QUICK_BOOKS_CONFIG: { - SYNC_CLASSES: 'syncClasses', + QUICKBOOKS_CONFIG: { ENABLE_NEW_CATEGORIES: 'enableNewCategories', + SYNC_CLASSES: 'syncClasses', SYNC_CUSTOMERS: 'syncCustomers', SYNC_LOCATIONS: 'syncLocations', SYNC_TAX: 'syncTax', EXPORT: 'export', + EXPORTER: 'exporter', EXPORT_DATE: 'exportDate', NON_REIMBURSABLE_EXPENSES_ACCOUNT: 'nonReimbursableExpensesAccount', NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'nonReimbursableExpensesExportDestination', REIMBURSABLE_EXPENSES_ACCOUNT: 'reimbursableExpensesAccount', REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'reimbursableExpensesExportDestination', NON_REIMBURSABLE_BILL_DEFAULT_VENDOR: 'nonReimbursableBillDefaultVendor', + NON_REIMBURSABLE_EXPENSE_EXPORT_DESTINATION: 'nonReimbursableExpensesExportDestination', + NON_REIMBURSABLE_EXPENSE_ACCOUNT: 'nonReimbursableExpensesAccount', RECEIVABLE_ACCOUNT: 'receivableAccount', AUTO_SYNC: 'autoSync', + ENABLED: 'enabled', SYNC_PEOPLE: 'syncPeople', AUTO_CREATE_VENDOR: 'autoCreateVendor', REIMBURSEMENT_ACCOUNT_ID: 'reimbursementAccountID', @@ -1914,6 +1996,11 @@ const CONST = { BUSINESS_BANK_ACCOUNT: 'businessBankAccount', }, + PAYMENT_SELECTED: { + BBA: 'BBA', + PBA: 'PBA', + }, + PAYMENT_METHOD_ID_KEYS: { DEBIT_CARD: 'fundID', BANK_ACCOUNT: 'bankAccountID', @@ -1988,6 +2075,10 @@ const CONST = { ACCESS_VARIANTS: { CREATE: 'create', }, + PAYMENT_SELECTED: { + BBA: 'BBA', + PBA: 'PBA', + }, }, GROWL: { @@ -2050,11 +2141,18 @@ const CONST = { // Often referred to as "collect" workspaces TEAM: 'team', }, + FIELD_LIST_TITLE_FIELD_ID: 'text_title', + DEFAULT_REPORT_NAME_PATTERN: '{report:type} {report:startdate}', ROLE: { ADMIN: 'admin', AUDITOR: 'auditor', USER: 'user', }, + AUTO_REIMBURSEMENT_MAX_LIMIT_CENTS: 2000000, + AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS: 10000, + AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS: 10000, + RANDOM_AUDIT_DEFAULT_PERCENTAGE: 5, + AUTO_REPORTING_FREQUENCIES: { INSTANT: 'instant', IMMEDIATE: 'immediate', @@ -3978,6 +4076,7 @@ const CONST = { SUBMITTER: 'submitter', ALL: 'all', }, + DELEGATE_ROLE_HELPDOT_ARTICLE_LINK: 'https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/', STRIPE_GBP_AUTH_STATUSES: { SUCCEEDED: 'succeeded', CARD_AUTHENTICATION_REQUIRED: 'authentication_required', @@ -4282,6 +4381,8 @@ const CONST = { ONBOARDING_INTRODUCTION: 'Let’s get you set up πŸ”§', ONBOARDING_CHOICES: {...onboardingChoices}, + SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices}, + ONBOARDING_INVITE_TYPES: {...onboardingInviteTypes}, ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE: 'What would you like to do with this expense?', ONBOARDING_CONCIERGE: { [onboardingChoices.EMPLOYER]: @@ -4324,49 +4425,8 @@ const CONST = { }, ONBOARDING_MESSAGES: { - [onboardingChoices.EMPLOYER]: { - message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', - video: { - url: `${CLOUDFRONT_URL}/videos/guided-setup-get-paid-back-v2.mp4`, - thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-get-paid-back.jpg`, - duration: 55, - width: 1280, - height: 960, - }, - tasks: [ - { - type: 'submitExpense', - autoCompleted: false, - title: 'Submit an expense', - description: - '*Submit an expense* by entering an amount or scanning a receipt.\n' + - '\n' + - 'Here’s how to submit an expense:\n' + - '\n' + - '1. Click the green *+* button.\n' + - '2. Choose *Submit expense*.\n' + - '3. Enter an amount or scan a receipt.\n' + - '4. Add your reimburser to the request.\n' + - '\n' + - 'Then, send your request and wait for that sweet β€œCha-ching!” when it’s complete.', - }, - { - type: 'enableWallet', - autoCompleted: false, - title: 'Enable your wallet', - description: - 'You’ll need to *enable your Expensify Wallet* to get paid back. Don’t worry, it’s easy!\n' + - '\n' + - 'Here’s how to set up your wallet:\n' + - '\n' + - '1. Click your profile picture.\n' + - '2. Click *Wallet* > *Enable wallet*.\n' + - '3. Connect your bank account.\n' + - '\n' + - 'Once that’s done, you can request money from anyone and get paid back right into your personal bank account.', - }, - ], - }, + [onboardingChoices.EMPLOYER]: onboardingEmployerOrSubmitMessage, + [onboardingChoices.SUBMIT]: onboardingEmployerOrSubmitMessage, [onboardingChoices.MANAGE_TEAM]: { message: 'Here are some important tasks to help get your team’s expenses under control.', video: { @@ -4395,7 +4455,7 @@ const CONST = { type: 'meetGuide', autoCompleted: false, title: 'Meet your setup specialist', - description: ({adminsRoomLink}: {adminsRoomLink: string}) => + description: ({adminsRoomLink}) => `Meet your setup specialist, who can answer any questions as you get started with Expensify. Yes, a real human!\n` + '\n' + `Chat with the specialist in your [#admins room](${adminsRoomLink}).`, @@ -4404,52 +4464,57 @@ const CONST = { type: 'setupCategories', autoCompleted: false, title: 'Set up categories', - description: ({workspaceLink}: {workspaceLink: string}) => + description: ({workspaceCategoriesLink}) => '*Set up categories* so your team can code expenses for easy reporting.\n' + '\n' + 'Here’s how to set up categories:\n' + '\n' + '1. Click your profile picture.\n' + - `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + - '3. Click *Categories*.\n' + - '4. Enable and disable default categories.\n' + - '5. Click *Add categories* to make your own.\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' + '\n' + - 'For more controls like requiring a category for every expense, click *Settings*.', + `[Take me to workspace category settings](${workspaceCategoriesLink}).`, }, { type: 'addExpenseApprovals', autoCompleted: false, title: 'Add expense approvals', - description: ({workspaceLink}: {workspaceLink: string}) => + description: ({workspaceMoreFeaturesLink}) => '*Add expense approvals* to review your team’s spend and keep it under control.\n' + '\n' + 'Here’s how to add expense approvals:\n' + '\n' + '1. Click your profile picture.\n' + - `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + - '3. Click *More features*.\n' + - '4. Enable *Workflows*.\n' + - '5. In *Workflows*, enable *Add approvals*.\n' + + '2. Go to Workspaces.\n' + + '3. Select your workspace.\n' + + '4. Click *More features*.\n' + + '5. Enable *Workflows*.\n' + + '6. In *Workflows*, enable *Add approvals*.\n' + + '7. You’ll be set as the expense approver. You can change this to any admin once you invite your team.\n' + '\n' + - 'You’ll be set as the expense approver. You can change this to any admin once you invite your team.', + `[Take me to enable more features](${workspaceMoreFeaturesLink}).`, }, { type: 'inviteTeam', autoCompleted: false, title: 'Invite your team', - description: ({workspaceLink}: {workspaceLink: string}) => + description: ({workspaceMembersLink}) => '*Invite your team* to Expensify so they can start tracking expenses today.\n' + '\n' + 'Here’s how to invite your team:\n' + '\n' + '1. Click your profile picture.\n' + - `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + - '3. Click *Members* > *Invite member*.\n' + - '4. Enter emails or phone numbers. \n' + - '5. Add an invite message if you want.\n' + + '2. Go to Workspaces.\n' + + '3. Select your workspace.\n' + + '4. Click *Members* > *Invite member*.\n' + + '5. Enter emails or phone numbers. \n' + + '6. Add an invite message if you want.\n' + '\n' + - 'That’s it! Happy expensing :)', + `[Take me to workspace members](${workspaceMembersLink}). That’s it, happy expensing! :)`, }, ], }, @@ -4546,7 +4611,7 @@ const CONST = { "Expensify is best known for expense and corporate card management, but we do a lot more than that. Let me know what you're interested in and I'll help get you started.", tasks: [], }, - }, + } satisfies Record, REPORT_FIELD_TITLE_FIELD_ID: 'text_title', @@ -5330,6 +5395,11 @@ const CONST = { DONE: 'done', PAID: 'paid', }, + CHAT_STATUS: { + UNREAD: 'unread', + PINNED: 'pinned', + DRAFT: 'draft', + }, BULK_ACTION_TYPES: { EXPORT: 'export', HOLD: 'hold', @@ -5360,10 +5430,15 @@ const CONST = { }, TRIP: { ALL: 'all', - DRAFTS: 'drafts', - OUTSTANDING: 'outstanding', - APPROVED: 'approved', - PAID: 'paid', + CURRENT: 'current', + PAST: 'past', + }, + CHAT: { + ALL: 'all', + UNREAD: 'unread', + SENT: 'sent', + ATTACHMENTS: 'attachments', + LINKS: 'links', }, }, CHAT_TYPES: { @@ -5418,6 +5493,7 @@ const CONST = { KEYWORD: 'keyword', IN: 'in', HAS: 'has', + IS: 'is', }, }, @@ -5618,6 +5694,6 @@ type FeedbackSurveyOptionID = ValueOf; type CancellationType = ValueOf; -export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType}; +export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType, OnboardingInviteType}; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d2a0372fd9c7..45b9a8c68bbb 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,5 +1,6 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; +import type {OnboardingPurposeType} from './CONST'; import type * as FormTypes from './types/form'; import type * as OnyxTypes from './types/onyx'; import type Onboarding from './types/onyx/Onboarding'; @@ -105,6 +106,9 @@ const ONYXKEYS = { /** Object containing contact method that's going to be added */ PENDING_CONTACT_ACTION: 'pendingContactAction', + /** Store the information of magic code */ + VALIDATE_ACTION_CODE: 'validate_action_code', + /** Information about the current session (authToken, accountID, email, loading, error) */ SESSION: 'session', STASHED_SESSION: 'stashedSession', @@ -336,6 +340,9 @@ const ONYXKEYS = { /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_ADMINS_CHAT_REPORT_ID: 'onboardingAdminsChatReportID', + // Stores onboarding last visited path + ONBOARDING_LAST_VISITED_PATH: 'onboardingLastVisitedPath', + // Max width supported for HTML element MAX_CANVAS_WIDTH: 'maxCanvasWidth', @@ -642,6 +649,14 @@ const ONYXKEYS = { SEARCH_ADVANCED_FILTERS_FORM_DRAFT: 'searchAdvancedFiltersFormDraft', TEXT_PICKER_MODAL_FORM: 'textPickerModalForm', TEXT_PICKER_MODAL_FORM_DRAFT: 'textPickerModalFormDraft', + RULES_CUSTOM_NAME_MODAL_FORM: 'rulesCustomNameModalForm', + RULES_CUSTOM_NAME_MODAL_FORM_DRAFT: 'rulesCustomNameModalFormDraft', + RULES_AUTO_APPROVE_REPORTS_UNDER_MODAL_FORM: 'rulesAutoApproveReportsUnderModalForm', + RULES_AUTO_APPROVE_REPORTS_UNDER_MODAL_FORM_DRAFT: 'rulesAutoApproveReportsUnderModalFormDraft', + RULES_RANDOM_REPORT_AUDIT_MODAL_FORM: 'rulesRandomReportAuditModalForm', + RULES_RANDOM_REPORT_AUDIT_MODAL_FORM_DRAFT: 'rulesRandomReportAuditModalFormDraft', + RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM: 'rulesAutoPayReportsUnderModalForm', + RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM_DRAFT: 'rulesAutoPayReportsUnderModalFormDraft', RULES_REQUIRED_RECEIPT_AMOUNT_FORM: 'rulesRequiredReceiptAmountForm', RULES_REQUIRED_RECEIPT_AMOUNT_FORM_DRAFT: 'rulesRequiredReceiptAmountFormDraft', RULES_MAX_EXPENSE_AMOUNT_FORM: 'rulesMaxExpenseAmountForm', @@ -729,6 +744,10 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.SAGE_INTACCT_DIMENSION_TYPE_FORM]: FormTypes.SageIntacctDimensionForm; [ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM]: FormTypes.SearchAdvancedFiltersForm; [ONYXKEYS.FORMS.TEXT_PICKER_MODAL_FORM]: FormTypes.TextPickerModalForm; + [ONYXKEYS.FORMS.RULES_CUSTOM_NAME_MODAL_FORM]: FormTypes.RulesCustomNameModalForm; + [ONYXKEYS.FORMS.RULES_AUTO_APPROVE_REPORTS_UNDER_MODAL_FORM]: FormTypes.RulesAutoApproveReportsUnderModalForm; + [ONYXKEYS.FORMS.RULES_RANDOM_REPORT_AUDIT_MODAL_FORM]: FormTypes.RulesRandomReportAuditModalForm; + [ONYXKEYS.FORMS.RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM]: FormTypes.RulesAutoPayReportsUnderModalForm; [ONYXKEYS.FORMS.RULES_REQUIRED_RECEIPT_AMOUNT_FORM]: FormTypes.RulesRequiredReceiptAmountForm; [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AMOUNT_FORM]: FormTypes.RulesMaxExpenseAmountForm; [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm; @@ -824,6 +843,7 @@ type OnyxValuesMapping = { [ONYXKEYS.USER_LOCATION]: OnyxTypes.UserLocation; [ONYXKEYS.LOGIN_LIST]: OnyxTypes.LoginList; [ONYXKEYS.PENDING_CONTACT_ACTION]: OnyxTypes.PendingContactAction; + [ONYXKEYS.VALIDATE_ACTION_CODE]: OnyxTypes.ValidateMagicCodeAction; [ONYXKEYS.SESSION]: OnyxTypes.Session; [ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata; [ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session; @@ -890,10 +910,11 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_AREA]: number; [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; - [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string; + [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: OnboardingPurposeType; [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; + [ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string; [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 89023063ad8f..a28c2ef4fc57 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -54,10 +54,16 @@ const ROUTES = { SEARCH_ADVANCED_FILTERS_TO: 'search/filters/to', SEARCH_ADVANCED_FILTERS_IN: 'search/filters/in', SEARCH_ADVANCED_FILTERS_HAS: 'search/filters/has', + SEARCH_ADVANCED_FILTERS_IS: 'search/filters/is', SEARCH_REPORT: { - route: 'search/view/:reportID', - getRoute: (reportID: string) => `search/view/${reportID}` as const, + route: 'search/view/:reportID/:reportActionID?', + getRoute: (reportID: string, reportActionID?: string) => { + if (reportActionID) { + return `search/view/${reportID}/${reportActionID}` as const; + } + return `search/view/${reportID}` as const; + }, }, TRANSACTION_HOLD_REASON_RHP: 'search/hold', @@ -704,7 +710,19 @@ const ROUTES = { }, POLICY_ACCOUNTING: { route: 'settings/workspaces/:policyID/accounting', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting` as const, + getRoute: (policyID: string, newConnectionName?: ConnectionName, integrationToDisconnect?: ConnectionName, shouldDisconnectIntegrationBeforeConnecting?: boolean) => { + let queryParams = ''; + if (newConnectionName) { + queryParams += `?newConnectionName=${newConnectionName}`; + if (integrationToDisconnect) { + queryParams += `&integrationToDisconnect=${integrationToDisconnect}`; + } + if (shouldDisconnectIntegrationBeforeConnecting !== undefined) { + queryParams += `&shouldDisconnectIntegrationBeforeConnecting=${shouldDisconnectIntegrationBeforeConnecting}`; + } + } + return `settings/workspaces/${policyID}/accounting${queryParams}` as const; + }, }, WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/advanced', @@ -990,6 +1008,22 @@ const ROUTES = { route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-rate/edit', getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-rate/edit` as const, }, + RULES_CUSTOM_NAME: { + route: 'settings/workspaces/:policyID/rules/name', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/name` as const, + }, + RULES_AUTO_APPROVE_REPORTS_UNDER: { + route: 'settings/workspaces/:policyID/rules/auto-approve', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/auto-approve` as const, + }, + RULES_RANDOM_REPORT_AUDIT: { + route: 'settings/workspaces/:policyID/rules/audit', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/audit` as const, + }, + RULES_AUTO_PAY_REPORTS_UNDER: { + route: 'settings/workspaces/:policyID/rules/auto-pay', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/auto-pay` as const, + }, RULES_RECEIPT_REQUIRED_AMOUNT: { route: 'settings/workspaces/:policyID/rules/receipt-required-amount', getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/receipt-required-amount` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index db790dd389c3..67ba5b84c9ec 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -48,6 +48,7 @@ const SCREENS = { ADVANCED_FILTERS_TO_RHP: 'Search_Advanced_Filters_To_RHP', ADVANCED_FILTERS_IN_RHP: 'Search_Advanced_Filters_In_RHP', ADVANCED_FILTERS_HAS_RHP: 'Search_Advanced_Filters_Has_RHP', + ADVANCED_FILTERS_IS_RHP: 'Search_Advanced_Filters_Is_RHP', TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', BOTTOM_TAB: 'Search_Bottom_Tab', }, @@ -452,6 +453,10 @@ const SCREENS = { DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit', UPGRADE: 'Workspace_Upgrade', RULES: 'Policy_Rules', + RULES_CUSTOM_NAME: 'Rules_Custom_Name', + RULES_AUTO_APPROVE_REPORTS_UNDER: 'Rules_Auto_Approve_Reports_Under', + RULES_RANDOM_REPORT_AUDIT: 'Rules_Random_Report_Audit', + RULES_AUTO_PAY_REPORTS_UNDER: 'Rules_AutoPay_Reports_Under', RULES_RECEIPT_REQUIRED_AMOUNT: 'Rules_Receipt_Required_Amount', RULES_MAX_EXPENSE_AMOUNT: 'Rules_Max_Expense_Amount', RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age', diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 7ca4cc3273ca..bd032df9a244 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -4,8 +4,8 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; -import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; +import CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/HomeAddressForm'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -14,6 +14,7 @@ import CountrySelector from './CountrySelector'; import FormProvider from './Form/FormProvider'; import InputWrapper from './Form/InputWrapper'; import type {FormOnyxValues} from './Form/types'; +import type {State} from './StateSelector'; import StateSelector from './StateSelector'; import TextInput from './TextInput'; @@ -192,7 +193,7 @@ function AddressForm({ diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index 8ad01d4437ae..f32d167e7133 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -43,11 +43,13 @@ type AmountFormProps = { /** Custom max amount length. It defaults to CONST.IOU.AMOUNT_MAX_LENGTH */ amountMaxLength?: number; + /** Custom label for the TextInput */ label?: string; + /** Whether the form should use a standard TextInput as a base */ displayAsTextInput?: boolean; } & Pick & - Pick; + Pick; /** * Returns the new selection object based on the updated amount's length @@ -67,7 +69,6 @@ function AmountForm( currency = CONST.CURRENCY.USD, extraDecimals = 0, amountMaxLength, - errorText, onInputChange, onCurrencyButtonPress, displayAsTextInput = false, @@ -296,11 +297,11 @@ function AmountForm( // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> - {!!errorText && ( + {!!rest.errorText && ( )} diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 14b7ac6f2313..c327d7fa6093 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -246,13 +246,14 @@ function AttachmentModal({ } if (typeof sourceURL === 'string') { - fileDownload(sourceURL, file?.name ?? ''); + const fileName = type === CONST.ATTACHMENT_TYPE.SEARCH ? FileUtils.getFileName(`${sourceURL}`) : file?.name; + fileDownload(sourceURL, fileName ?? ''); } // At ios, if the keyboard is open while opening the attachment, then after downloading // the attachment keyboard will show up. So, to fix it we need to dismiss the keyboard. Keyboard.dismiss(); - }, [isAuthTokenRequiredState, sourceState, file]); + }, [isAuthTokenRequiredState, sourceState, file, type]); /** * Execute the onConfirm callback and close the modal. @@ -460,7 +461,7 @@ function AttachmentModal({ let headerTitleNew = headerTitle; let shouldShowDownloadButton = false; let shouldShowThreeDotsButton = false; - if (!isEmptyObject(report)) { + if (!isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH) { headerTitleNew = translate(isReceiptAttachment ? 'common.receipt' : 'common.attachment'); shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isReceiptAttachment && !isOffline && !isLocalSource; shouldShowThreeDotsButton = isReceiptAttachment && isModalOpen && threeDotsMenuItems.length !== 0; diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 2ccdd47c3205..1cd1bfb36d83 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -12,7 +12,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; +import type {PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; import CaretWrapper from './CaretWrapper'; import DisplayNames from './DisplayNames'; import MultipleAvatars from './MultipleAvatars'; @@ -69,7 +69,7 @@ function AvatarWithDisplayName({ ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report) || ReportUtils.isInvoiceReport(report); const icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails) as PersonalDetails[], false); + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false); const shouldShowSubscriptAvatar = ReportUtils.shouldReportShowSubscript(report); const avatarBorderColor = isAnonymous ? theme.highlightBG : theme.componentBG; diff --git a/src/components/BulletList.tsx b/src/components/BulletList.tsx new file mode 100644 index 000000000000..8aee1aa5076f --- /dev/null +++ b/src/components/BulletList.tsx @@ -0,0 +1,52 @@ +import type {ReactNode} from 'react'; +import React from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Text from './Text'; + +type BulletListItem = string; + +type BulletListProps = { + /** List of items for the list. Each item will be rendered as a sepearte point. */ + items: BulletListItem[]; + + /** Header section of the list */ + header: string | ReactNode; +}; + +function BulletList({items, header}: BulletListProps) { + const styles = useThemeStyles(); + + const baseTextStyles = [styles.mutedNormalTextLabel]; + + const renderBulletListHeader = () => { + if (typeof header === 'string') { + return {header}; + } + return header; + }; + + const renderBulletPoint = (item: string) => { + return ( + + β€’ + {item} + + ); + }; + + return ( + + {renderBulletListHeader()} + {items.map((item) => renderBulletPoint(item))} + + ); +} + +BulletList.displayName = 'BulletList'; + +export type {BulletListProps}; +export default BulletList; diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 0abc55088647..ddab08bdc1d3 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -66,7 +66,7 @@ function ButtonWithDropdownMenu({ if ('measureInWindow' in dropdownAnchor.current) { dropdownAnchor.current.measureInWindow((x, y, w, h) => { setPopoverAnchorPosition({ - horizontal: x + w + h, + horizontal: x + w, vertical: anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP ? y + h + CONST.MODAL.POPOVER_MENU_PADDING // if vertical anchorAlignment is TOP, menu will open below the button and we need to add the height of button and padding diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 82b26a39e1db..58ad58ce4e68 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -1,13 +1,12 @@ import type {RefObject} from 'react'; import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; -import type {PaymentMethodType} from '@components/KYCWall/types'; import type CONST from '@src/CONST'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; -type PaymentType = DeepValueOf; +type PaymentType = DeepValueOf; type WorkspaceMemberBulkActionType = DeepValueOf; diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index e9558297e577..cc99ca636488 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -19,6 +19,9 @@ type ConfirmModalProps = { /** A callback to call when the form has been closed */ onCancel?: () => void; + /** A callback to call when backdrop is pressed */ + onBackdropPress?: () => void; + /** Modal visibility */ isVisible: boolean; @@ -108,6 +111,7 @@ function ConfirmModal({ success = true, danger = false, onCancel = () => {}, + onBackdropPress = () => {}, shouldDisableConfirmButtonWhenOffline = false, shouldShowCancelButton = true, shouldSetModalVisibility = true, @@ -140,6 +144,7 @@ function ConfirmModal({ void; + delegatorEmail: string; +}; + +export default function DelegateNoAccessModal({isNoDelegateAccessMenuVisible = false, onClose, delegatorEmail = ''}: DelegateNoAccessModalProps) { + const {translate} = useLocalize(); + const noDelegateAccessPromptStart = translate('delegate.notAllowedMessageStart', {accountOwnerEmail: delegatorEmail}); + const noDelegateAccessHyperLinked = translate('delegate.notAllowedMessageHyperLinked'); + const noDelegateAccessPromptEnd = translate('delegate.notAllowedMessageEnd'); + + const delegateNoAccessPrompt = ( + + {noDelegateAccessPromptStart} + {noDelegateAccessHyperLinked} + {noDelegateAccessPromptEnd} + + ); + + return ( + + ); +} diff --git a/src/components/EReceipt.tsx b/src/components/EReceipt.tsx index bfb59dc748ab..026713027f96 100644 --- a/src/components/EReceipt.tsx +++ b/src/components/EReceipt.tsx @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -44,7 +45,7 @@ function EReceipt({transaction, transactionID}: EReceiptProps) { const formattedAmount = CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency); const currency = CurrencyUtils.getCurrencySymbol(transactionCurrency ?? ''); const amount = currency ? formattedAmount.replace(currency, '') : formattedAmount; - const cardDescription = transactionCardID ? CardUtils.getCardDescription(transactionCardID) : ''; + const cardDescription = TransactionUtils.getCardName(transaction) ?? (transactionCardID ? CardUtils.getCardDescription(transactionCardID) : ''); const secondaryTextColorStyle = secondaryColor ? StyleUtils.getColorStyle(secondaryColor) : undefined; diff --git a/src/components/ExplanationModal.tsx b/src/components/ExplanationModal.tsx index c6294f600993..73290c43d39a 100644 --- a/src/components/ExplanationModal.tsx +++ b/src/components/ExplanationModal.tsx @@ -3,8 +3,8 @@ 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 ROUTES from '@src/ROUTES'; import FeatureTrainingModal from './FeatureTrainingModal'; function ExplanationModal() { @@ -18,7 +18,7 @@ function ExplanationModal() { onNotCompleted: () => { setTimeout(() => { Navigation.isNavigationReady().then(() => { - Navigation.navigate(ROUTES.ONBOARDING_ROOT.route); + OnboardingFlow.startOnboardingFlow(); }); }, variables.welcomeVideoDelay); }, diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 5f56bbeceea6..88ccc31c0979 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -11,6 +11,7 @@ import type CountrySelector from '@components/CountrySelector'; import type CurrencySelector from '@components/CurrencySelector'; import type DatePicker from '@components/DatePicker'; import type EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown'; +import type PercentageForm from '@components/PercentageForm'; import type Picker from '@components/Picker'; import type RadioButtons from '@components/RadioButtons'; import type RoomNameInput from '@components/RoomNameInput'; @@ -42,6 +43,7 @@ type ValidInputs = | typeof CountrySelector | typeof CurrencySelector | typeof AmountForm + | typeof PercentageForm | typeof BusinessTypePicker | typeof DimensionTypeSelector | typeof StateSelector diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 771d2631379e..99699b9ef3c6 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -90,10 +90,8 @@ function ImageRenderer({tnode}: ImageRendererProps) { return; } - if (reportID) { - const route = ROUTES.ATTACHMENTS?.getRoute(reportID, type, source, accountID); - Navigation.navigate(route); - } + const route = ROUTES.ATTACHMENTS?.getRoute(reportID ?? '-1', type, source, accountID); + Navigation.navigate(route); }} onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx index e0df7e7081c5..ce822af14cb8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type {CustomRendererProps, TBlock} from 'react-native-render-html'; +import {AttachmentContext} from '@components/AttachmentContext'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import VideoPlayerPreview from '@components/VideoPlayerPreview'; import useCurrentReportID from '@hooks/useCurrentReportID'; @@ -28,19 +29,26 @@ function VideoRenderer({tnode, key}: VideoRendererProps) { return ( {({report}) => ( - { - const route = ROUTES.ATTACHMENTS.getRoute(report?.reportID ?? '-1', CONST.ATTACHMENT_TYPE.REPORT, sourceURL); - Navigation.navigate(route); - }} - /> + + {({accountID, type}) => ( + { + if (!sourceURL || !type) { + return; + } + const route = ROUTES.ATTACHMENTS.getRoute(report?.reportID ?? '-1', type, sourceURL, accountID); + Navigation.navigate(route); + }} + /> + )} + )} ); diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 0616794a8e3a..bc12bc6c135b 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -56,6 +56,7 @@ import CheckmarkCircle from '@assets/images/simple-illustrations/simple-illustra import CoffeeMug from '@assets/images/simple-illustrations/simple-illustration__coffeemug.svg'; import Coins from '@assets/images/simple-illustrations/simple-illustration__coins.svg'; import CommentBubbles from '@assets/images/simple-illustrations/simple-illustration__commentbubbles.svg'; +import CommentBubblesBlue from '@assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg'; import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustration__concierge-bubble.svg'; import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg'; import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; @@ -182,6 +183,7 @@ export { SmartScan, Hourglass, CommentBubbles, + CommentBubblesBlue, TrashCan, TeleScope, Profile, diff --git a/src/components/ImportColumn.tsx b/src/components/ImportColumn.tsx index f25082f94474..6ede4ed490b0 100644 --- a/src/components/ImportColumn.tsx +++ b/src/components/ImportColumn.tsx @@ -156,7 +156,7 @@ function ImportColumn({column, columnName, columnRoles, columnIndex}: ImportColu text: item.text, value: item.value, description: item.description ?? (item.isRequired ? translate('common.required') : undefined), - isSelected: spreadsheet?.columns[columnIndex] === item.value, + isSelected: spreadsheet?.columns?.[columnIndex] === item.value, })); const columnValuesString = column.slice(containsHeader ? 1 : 0).join(', '); diff --git a/src/components/ImportSpreadsheet.tsx b/src/components/ImportSpreadsheet.tsx index cdfc5bd05a36..fd75bfc2d3b5 100644 --- a/src/components/ImportSpreadsheet.tsx +++ b/src/components/ImportSpreadsheet.tsx @@ -75,15 +75,15 @@ function ImportSpreedsheet({backTo, goTo}: ImportSpreedsheetProps) { if (!validateFile(file)) { return; } - if (!file.uri) { + let fileURI = file.uri ?? URL.createObjectURL(file); + if (!fileURI) { return; } - let filePath = file.uri; if (Platform.OS === 'ios') { - filePath = filePath.replace(/^.*\/Documents\//, `${RNFetchBlob.fs.dirs.DocumentDir}/`); + fileURI = fileURI.replace(/^.*\/Documents\//, `${RNFetchBlob.fs.dirs.DocumentDir}/`); } - fetch(filePath) + fetch(fileURI) .then((data) => { setIsReadingFIle(true); return data.arrayBuffer(); @@ -102,6 +102,9 @@ function ImportSpreedsheet({backTo, goTo}: ImportSpreedsheetProps) { }) .finally(() => { setIsReadingFIle(false); + if (fileURI && !file.uri) { + URL.revokeObjectURL(fileURI); + } }); }; @@ -189,7 +192,7 @@ function ImportSpreedsheet({backTo, goTo}: ImportSpreedsheetProps) { height={CONST.IMPORT_SPREADSHEET.ICON_HEIGHT} /> {translate('common.dropTitle')} - {translate('common.dropMessage')} + {translate('common.dropMessage')} diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 625973bbbe59..fd681546c470 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -1,12 +1,16 @@ -import React, {useCallback, useRef} from 'react'; -import type {GestureResponderEvent, View} from 'react-native'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {Dimensions} from 'react-native'; +import type {EmitterSubscription, GestureResponderEvent, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu'; import * as BankAccounts from '@libs/actions/BankAccounts'; +import getClickedTargetLocation from '@libs/getClickedTargetLocation'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import * as PaymentUtils from '@libs/PaymentUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as PaymentMethods from '@userActions/PaymentMethods'; import * as Policy from '@userActions/Policy/Policy'; import * as Wallet from '@userActions/Wallet'; import CONST from '@src/CONST'; @@ -15,7 +19,10 @@ import ROUTES from '@src/ROUTES'; import type {BankAccountList, FundList, ReimbursementAccount, UserWallet, WalletTerms} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import viewRef from '@src/types/utils/viewRef'; -import type {KYCWallProps, PaymentMethod} from './types'; +import type {AnchorPosition, DomRect, KYCWallProps, PaymentMethod} from './types'; + +// This sets the Horizontal anchor position offset for POPOVER MENU. +const POPOVER_MENU_ANCHOR_POSITION_HORIZONTAL_OFFSET = 20; type BaseKYCWallOnyxProps = { /** The user's wallet */ @@ -42,6 +49,10 @@ type BaseKYCWallProps = KYCWallProps & BaseKYCWallOnyxProps; function KYCWall({ addBankAccountRoute, addDebitCardRoute, + anchorAlignment = { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }, bankAccountList = {}, chatReportID = '', children, @@ -52,13 +63,60 @@ function KYCWall({ onSuccessfulKYC, reimbursementAccount, shouldIncludeDebitCard = true, + shouldListenForResize = false, source, userWallet, walletTerms, + shouldShowPersonalBankAccountOption = false, }: BaseKYCWallProps) { const anchorRef = useRef(null); const transferBalanceButtonRef = useRef(null); + const [shouldShowAddPaymentMenu, setShouldShowAddPaymentMenu] = useState(false); + + const [anchorPosition, setAnchorPosition] = useState({ + anchorPositionVertical: 0, + anchorPositionHorizontal: 0, + }); + + const getAnchorPosition = useCallback( + (domRect: DomRect): AnchorPosition => { + if (anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) { + return { + anchorPositionVertical: domRect.top + domRect.height + CONST.MODAL.POPOVER_MENU_PADDING, + anchorPositionHorizontal: domRect.left + POPOVER_MENU_ANCHOR_POSITION_HORIZONTAL_OFFSET, + }; + } + + return { + anchorPositionVertical: domRect.top - CONST.MODAL.POPOVER_MENU_PADDING, + anchorPositionHorizontal: domRect.left, + }; + }, + [anchorAlignment.vertical], + ); + + /** + * Set position of the transfer payment menu + */ + const setPositionAddPaymentMenu = ({anchorPositionVertical, anchorPositionHorizontal}: AnchorPosition) => { + setAnchorPosition({ + anchorPositionVertical, + anchorPositionHorizontal, + }); + }; + + const setMenuPosition = useCallback(() => { + if (!transferBalanceButtonRef.current) { + return; + } + + const buttonPosition = getClickedTargetLocation(transferBalanceButtonRef.current as HTMLDivElement); + const position = getAnchorPosition(buttonPosition); + + setPositionAddPaymentMenu(position); + }, [getAnchorPosition]); + const selectPaymentMethod = useCallback( (paymentMethod: PaymentMethod) => { onSelectPaymentMethod(paymentMethod); @@ -101,6 +159,11 @@ function KYCWall({ */ Wallet.setKYCWallSource(source, chatReportID); + if (shouldShowAddPaymentMenu) { + setShouldShowAddPaymentMenu(false); + return; + } + // Use event target as fallback if anchorRef is null for safety const targetElement = anchorRef.current ?? (event?.currentTarget as HTMLDivElement); @@ -121,19 +184,11 @@ function KYCWall({ return; } - switch (iouPaymentType) { - case CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT: - selectPaymentMethod(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); - break; - case CONST.PAYMENT_METHODS.DEBIT_CARD: - selectPaymentMethod(CONST.PAYMENT_METHODS.DEBIT_CARD); - break; - case CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT: - selectPaymentMethod(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT); - break; - default: - break; - } + const clickedElementLocation = getClickedTargetLocation(targetElement as HTMLDivElement); + const position = getAnchorPosition(clickedElementLocation); + + setPositionAddPaymentMenu(position); + setShouldShowAddPaymentMenu(true); return; } @@ -159,18 +214,58 @@ function KYCWall({ chatReportID, enablePaymentsRoute, fundList, + getAnchorPosition, iouReport, onSuccessfulKYC, reimbursementAccount?.achData?.state, selectPaymentMethod, shouldIncludeDebitCard, + shouldShowAddPaymentMenu, source, userWallet?.tierName, walletTerms?.source, ], ); - return <>{children(continueAction, viewRef(anchorRef))}; + useEffect(() => { + let dimensionsSubscription: EmitterSubscription | null = null; + + PaymentMethods.kycWallRef.current = {continueAction}; + + if (shouldListenForResize) { + dimensionsSubscription = Dimensions.addEventListener('change', setMenuPosition); + } + + return () => { + if (shouldListenForResize && dimensionsSubscription) { + dimensionsSubscription.remove(); + } + + PaymentMethods.kycWallRef.current = null; + }; + }, [chatReportID, setMenuPosition, shouldListenForResize, continueAction]); + + return ( + <> + setShouldShowAddPaymentMenu(false)} + anchorRef={anchorRef} + anchorPosition={{ + vertical: anchorPosition.anchorPositionVertical, + horizontal: anchorPosition.anchorPositionHorizontal, + }} + anchorAlignment={anchorAlignment} + onItemSelected={(item: PaymentMethod) => { + setShouldShowAddPaymentMenu(false); + selectPaymentMethod(item); + }} + shouldShowPersonalBankAccountOption={shouldShowPersonalBankAccountOption} + /> + {children(continueAction, viewRef(anchorRef))} + + ); } KYCWall.displayName = 'BaseKYCWall'; diff --git a/src/components/LocationPermissionModal/index.android.tsx b/src/components/LocationPermissionModal/index.android.tsx index 811537e00e67..30896cf37084 100644 --- a/src/components/LocationPermissionModal/index.android.tsx +++ b/src/components/LocationPermissionModal/index.android.tsx @@ -63,11 +63,17 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe setHasError(false); }; + const closeModal = () => { + setShowModal(false); + resetPermissionFlow(); + }; + return ( { + setShowModal(false); + resetPermissionFlow(); + }; return ( { if (!type || !chatReport) { @@ -147,7 +151,9 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea } setPaymentType(type); setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); - if (isAnyTransactionOnHold) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (isAnyTransactionOnHold) { setIsHoldMenuVisible(true); } else if (ReportUtils.isInvoiceReport(moneyRequestReport)) { IOU.payInvoice(type, chatReport, moneyRequestReport, payAsBusiness); @@ -158,7 +164,9 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const confirmApproval = () => { setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); - if (isAnyTransactionOnHold) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (isAnyTransactionOnHold) { setIsHoldMenuVisible(true); } else { IOU.approveMoneyRequest(moneyRequestReport, true); @@ -400,6 +408,12 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea transactionCount={transactionIDs.length} /> )} + setIsNoDelegateAccessMenuVisible(false)} + delegatorEmail={delegatorEmail ?? ''} + /> + void; + + /** Custom label for the TextInput */ + label?: string; +}; + +/** + * Returns the new selection object based on the updated amount's length + */ +const getNewSelection = (oldSelection: {start: number; end: number}, prevLength: number, newLength: number) => { + const cursorPosition = oldSelection.end + (newLength - prevLength); + return {start: cursorPosition, end: cursorPosition}; +}; + +function PercentageForm({value: amount, errorText, onInputChange, label, ...rest}: PercentageFormProps, forwardedRef: ForwardedRef) { + const {toLocaleDigit, numberFormat} = useLocalize(); + + const textInput = useRef(null); + + const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); + + const [selection, setSelection] = useState({ + start: currentAmount.length, + end: currentAmount.length, + }); + + const forwardDeletePressedRef = useRef(false); + + /** + * Sets the selection and the amount accordingly to the value passed to the input + * @param newAmount - Changed amount from user input + */ + const setNewAmount = useCallback( + (newAmount: string) => { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); + // Use a shallow copy of selection to trigger setSelection + // More info: https://github.com/Expensify/App/issues/16385 + if (!MoneyRequestUtils.validatePercentage(newAmountWithoutSpaces)) { + setSelection((prevSelection) => ({...prevSelection})); + return; + } + + const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); + const isForwardDelete = currentAmount.length > strippedAmount.length && forwardDeletePressedRef.current; + setSelection(getNewSelection(selection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length)); + onInputChange?.(strippedAmount); + }, + [currentAmount, onInputChange, selection], + ); + + const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); + + return ( + { + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef && 'current' in forwardedRef) { + // eslint-disable-next-line no-param-reassign + forwardedRef.current = ref; + } + textInput.current = ref; + }} + selection={selection} + onSelectionChange={(e: NativeSyntheticEvent) => { + setSelection(e.nativeEvent.selection); + }} + suffixCharacter="%" + keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + /> + ); +} + +PercentageForm.displayName = 'PercentageForm'; + +export default forwardRef(PercentageForm); +export type {PercentageFormProps}; diff --git a/src/components/PromotedActionsBar.tsx b/src/components/PromotedActionsBar.tsx index b0309d702f9a..bf7b1aeff003 100644 --- a/src/components/PromotedActionsBar.tsx +++ b/src/components/PromotedActionsBar.tsx @@ -29,7 +29,13 @@ type BasePromotedActions = typeof CONST.PROMOTED_ACTIONS.PIN | typeof CONST.PROM type PromotedActionsType = Record PromotedAction> & { message: (params: {reportID?: string; accountID?: number; login?: string}) => PromotedAction; } & { - hold: (params: {isTextHold: boolean; reportAction: ReportAction | undefined; reportID?: string}) => PromotedAction; + hold: (params: { + isTextHold: boolean; + reportAction: ReportAction | undefined; + reportID?: string; + isDelegateAccessRestricted: boolean; + setIsNoDelegateAccessMenuVisible: (isVisible: boolean) => void; + }) => PromotedAction; }; const PromotedActions = { @@ -70,11 +76,16 @@ const PromotedActions = { } }, }), - hold: ({isTextHold, reportAction, reportID}) => ({ + hold: ({isTextHold, reportAction, reportID, isDelegateAccessRestricted, setIsNoDelegateAccessMenuVisible}) => ({ key: CONST.PROMOTED_ACTIONS.HOLD, icon: Expensicons.Stopwatch, text: Localize.translateLocal(`iou.${isTextHold ? 'hold' : 'unhold'}`), onSelected: () => { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); // Show the menu + return; + } + if (!isTextHold) { Navigation.goBack(); } diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 6ab1c0937278..f422269bfc69 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -136,7 +136,7 @@ function MoneyRequestPreviewContent({ const duplicates = useMemo(() => TransactionUtils.removeSettledAndApprovedTransactions(allDuplicates), [allDuplicates]); // When there are no settled transactions in duplicates, show the "Keep this one" button - const shouldShowKeepButton = allDuplicates.length === duplicates.length; + const shouldShowKeepButton = !!(allDuplicates.length && duplicates.length && allDuplicates.length === duplicates.length); const hasDuplicates = duplicates.length > 0; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 792ebb176900..bfcf17ef617b 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -5,6 +5,7 @@ import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useOnyx, withOnyx} from 'react-native-onyx'; import Button from '@components/Button'; +import DelegateNoAccessModal from '@components/DelegateNoAccessModal'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -14,6 +15,7 @@ import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; import SettlementButton from '@components/SettlementButton'; import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; +import useDelegateUserDetails from '@hooks/useDelegateUserDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; @@ -191,13 +193,18 @@ function ReportPreview({ [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled], ); + const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); + const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); + const confirmPayment = (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type) { return; } setPaymentType(type); setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); - if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else if (chatReport && iouReport) { if (ReportUtils.isInvoiceReport(iouReport)) { @@ -210,7 +217,9 @@ function ReportPreview({ const confirmApproval = () => { setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); - if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else { IOU.approveMoneyRequest(iouReport, true); @@ -516,6 +525,12 @@ function ReportPreview({ + setIsNoDelegateAccessMenuVisible(false)} + delegatorEmail={delegatorEmail ?? ''} + /> + {isHoldMenuVisible && iouReport && requestType !== undefined && ( void; setOfflineModalOpen?: () => void; setDownloadErrorModalOpen?: () => void; - data?: TransactionListItemType[] | ReportListItemType[]; + data?: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[]; }; type SearchHeaderOptionValue = DeepValueOf | undefined; @@ -111,6 +111,8 @@ function getHeaderContent(type: SearchDataTypes): HeaderContent { return {icon: Illustrations.EnvelopeReceipt, titleText: 'workspace.common.invoices'}; case CONST.SEARCH.DATA_TYPES.TRIP: return {icon: Illustrations.Luggage, titleText: 'travel.trips'}; + case CONST.SEARCH.DATA_TYPES.CHAT: + return {icon: Illustrations.CommentBubblesBlue, titleText: 'common.chats'}; case CONST.SEARCH.DATA_TYPES.EXPENSE: default: return {icon: Illustrations.MoneyReceipts, titleText: 'common.expenses'}; @@ -135,6 +137,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa .filter( (item) => !SearchUtils.isTransactionListItemType(item) && + !SearchUtils.isReportActionListItemType(item) && item.reportID && item.transactions.every((transaction: {keyForList: string | number}) => selectedTransactions[transaction.keyForList]?.isSelected), ) diff --git a/src/components/Search/SearchStatusBar.tsx b/src/components/Search/SearchStatusBar.tsx index 7c1ffeff1818..b8b2b3fd05d0 100644 --- a/src/components/Search/SearchStatusBar.tsx +++ b/src/components/Search/SearchStatusBar.tsx @@ -13,11 +13,12 @@ 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 {ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryString, SearchStatus, TripSearchStatus} from './types'; +import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryString, SearchStatus, TripSearchStatus} from './types'; type SearchStatusBarProps = { type: SearchDataTypes; status: SearchStatus; + resetOffset: () => void; }; const expenseOptions: Array<{key: ExpenseSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ @@ -82,28 +83,49 @@ const tripOptions: Array<{key: TripSearchStatus; icon: IconAsset; text: Translat query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.ALL), }, { - key: CONST.SEARCH.STATUS.TRIP.DRAFTS, - icon: Expensicons.Pencil, - text: 'common.drafts', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.DRAFTS), + key: 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.OUTSTANDING, - icon: Expensicons.Hourglass, - text: 'common.outstanding', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.OUTSTANDING), + key: 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}> = [ { - key: CONST.SEARCH.STATUS.TRIP.APPROVED, - icon: Expensicons.ThumbsUp, - text: 'iou.approved', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.APPROVED), + key: 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.TRIP.PAID, - icon: Expensicons.MoneyBag, - text: 'iou.settledExpensify', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.PAID), + key: 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, + 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, + 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, + icon: Expensicons.Paperclip, + text: 'common.links', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.LINKS), }, ]; @@ -113,13 +135,15 @@ function getOptions(type: SearchDataTypes) { return invoiceOptions; case CONST.SEARCH.DATA_TYPES.TRIP: return tripOptions; + case CONST.SEARCH.DATA_TYPES.CHAT: + return chatOptions; case CONST.SEARCH.DATA_TYPES.EXPENSE: default: return expenseOptions; } } -function SearchStatusBar({type, status}: SearchStatusBarProps) { +function SearchStatusBar({type, status, resetOffset}: SearchStatusBarProps) { const {singleExecution} = useSingleExecution(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -134,7 +158,10 @@ function SearchStatusBar({type, status}: SearchStatusBarProps) { showsHorizontalScrollIndicator={false} > {options.map((item, index) => { - const onPress = singleExecution(() => Navigation.setParams({q: item.query})); + const onPress = singleExecution(() => { + resetOffset(); + Navigation.setParams({q: item.query}); + }); const isActive = status === item.key; const isFirstItem = index === 0; const isLastItem = index === options.length - 1; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 8c4530b08b64..8296b494b6fc 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -3,10 +3,11 @@ import type {StackNavigationProp} from '@react-navigation/stack'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; import SearchTableHeader from '@components/SelectionList/SearchTableHeader'; -import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import SearchStatusSkeleton from '@components/Skeletons/SearchStatusSkeleton'; @@ -54,7 +55,10 @@ function mapToTransactionItemWithSelectionInfo(item: TransactionListItemType, se return {...item, isSelected: selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple}; } -function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean) { +function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType | ReportActionListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean) { + if (SearchUtils.isReportActionListItemType(item)) { + return item; + } return SearchUtils.isTransactionListItemType(item) ? mapToTransactionItemWithSelectionInfo(item, selectedTransactions, canSelectMultiple) : { @@ -142,8 +146,8 @@ function Search({queryJSON}: SearchProps) { }; const getItemHeight = useCallback( - (item: TransactionListItemType | ReportListItemType) => { - if (SearchUtils.isTransactionListItemType(item)) { + (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { + if (SearchUtils.isTransactionListItemType(item) || SearchUtils.isReportActionListItemType(item)) { return isLargeScreenWidth ? variables.optionRowHeight + listItemPadding : transactionItemMobileHeight + listItemPadding; } @@ -161,6 +165,8 @@ function Search({queryJSON}: SearchProps) { [isLargeScreenWidth], ); + const resetOffset = () => setOffset(0); + const getItemHeightMemoized = memoize(getItemHeight, { transformKey: ([item]) => { // List items are displayed differently on "L"arge and "N"arrow screens so the height will differ @@ -205,6 +211,7 @@ function Search({queryJSON}: SearchProps) { ) : ( @@ -216,12 +223,12 @@ function Search({queryJSON}: SearchProps) { if (searchResults === undefined) { Log.alert('[Search] Undefined search type'); - return null; + return {null}; } - const ListItem = SearchUtils.getListItem(status); - const data = SearchUtils.getSections(status, searchResults.data, searchResults.search); - const sortedData = SearchUtils.getSortedSections(status, data, sortBy, sortOrder); + const ListItem = SearchUtils.getListItem(type, status); + const data = SearchUtils.getSections(type, status, searchResults.data, searchResults.search); + const sortedData = SearchUtils.getSortedSections(type, status, data, sortBy, sortOrder); const sortedSelectedData = sortedData.map((item) => mapToItemWithSelectionInfo(item, selectedTransactions, canSelectMultiple)); const shouldShowEmptyState = !isDataLoaded || data.length === 0; @@ -236,13 +243,17 @@ function Search({queryJSON}: SearchProps) { ); } - const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => { + const toggleTransaction = (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { + if (SearchUtils.isReportActionListItemType(item)) { + return; + } if (SearchUtils.isTransactionListItemType(item)) { if (!item.keyForList) { return; @@ -269,7 +280,7 @@ function Search({queryJSON}: SearchProps) { }); }; - const openReport = (item: TransactionListItemType | ReportListItemType) => { + const openReport = (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { let reportID = SearchUtils.isTransactionListItemType(item) && !item.isFromOneTransactionReport ? item.transactionThreadReportID : item.reportID; if (!reportID) { @@ -282,6 +293,12 @@ function Search({queryJSON}: SearchProps) { SearchActions.createTransactionThread(hash, item.transactionID, reportID, item.moneyRequestReportActionID); } + if (SearchUtils.isReportActionListItemType(item)) { + const reportActionID = item.reportActionID; + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(reportID, reportActionID)); + return; + } + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(reportID)); }; @@ -332,10 +349,11 @@ function Search({queryJSON}: SearchProps) { - + sections={[{data: sortedSelectedData, isDisabled: false}]} - turnOnSelectionModeOnLongPress + turnOnSelectionModeOnLongPress={type !== CONST.SEARCH.DATA_TYPES.CHAT} onTurnOnSelectionMode={(item) => item && toggleTransaction(item)} onCheckboxPress={toggleTransaction} onSelectAll={toggleAllTransactions} @@ -352,7 +370,7 @@ function Search({queryJSON}: SearchProps) { /> ) } - canSelectMultiple={canSelectMultiple} + canSelectMultiple={type !== CONST.SEARCH.DATA_TYPES.CHAT && canSelectMultiple} customListHeaderHeight={searchHeaderHeight} // To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling, // we have configured a larger windowSize and a longer delay between batch renders. diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 9f2aca1ff957..b22c8e58e122 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -28,7 +28,8 @@ type SearchColumnType = ValueOf; type ExpenseSearchStatus = ValueOf; type InvoiceSearchStatus = ValueOf; type TripSearchStatus = ValueOf; -type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus; +type ChatSearchStatus = ValueOf; +type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus | ChatSearchStatus; type SearchContext = { currentSearchHash: number; @@ -41,7 +42,7 @@ type SearchContext = { type ASTNode = { operator: ValueOf; left: ValueOf | ASTNode; - right: string | ASTNode; + right: string | ASTNode | string[]; }; type QueryFilter = { @@ -88,4 +89,5 @@ export type { ExpenseSearchStatus, InvoiceSearchStatus, TripSearchStatus, + ChatSearchStatus, }; diff --git a/src/components/SelectionList/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx new file mode 100644 index 000000000000..1a27e0ecbfcf --- /dev/null +++ b/src/components/SelectionList/ChatListItem.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import {View} from 'react-native'; +import {AttachmentContext} from '@components/AttachmentContext'; +import MultipleAvatars from '@components/MultipleAvatars'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ReportActionItemDate from '@pages/home/report/ReportActionItemDate'; +import ReportActionItemFragment from '@pages/home/report/ReportActionItemFragment'; +import CONST from '@src/CONST'; +import BaseListItem from './BaseListItem'; +import type {ChatListItemProps, ListItem, ReportActionListItemType} from './types'; + +function ChatListItem({ + item, + isFocused, + showTooltip, + isDisabled, + canSelectMultiple, + onSelectRow, + onDismissError, + onFocus, + onLongPressRow, + shouldSyncFocus, +}: ChatListItemProps) { + const reportActionItem = item as unknown as ReportActionListItemType; + const from = reportActionItem.from; + const icons = [ + { + type: CONST.ICON_TYPE_AVATAR, + source: from.avatar, + name: reportActionItem.formattedFrom, + id: from.accountID, + }, + ]; + const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + + const attachmentContextValue = {type: CONST.ATTACHMENT_TYPE.SEARCH}; + + const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; + const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; + + return ( + + {(hovered) => ( + + + + + + + + + + + {reportActionItem.message.map((fragment, index) => ( + + ))} + + + + )} + + ); +} + +ChatListItem.displayName = 'ChatListItem'; + +export default ChatListItem; diff --git a/src/components/SelectionList/SearchTableHeader.tsx b/src/components/SelectionList/SearchTableHeader.tsx index cb1914824a20..f54532a7f318 100644 --- a/src/components/SelectionList/SearchTableHeader.tsx +++ b/src/components/SelectionList/SearchTableHeader.tsx @@ -85,6 +85,13 @@ const expenseHeaders: SearchColumnConfig[] = [ }, ]; +const SearchColumns = { + [CONST.SEARCH.DATA_TYPES.EXPENSE]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.INVOICE]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.TRIP]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.CHAT]: null, +}; + type SearchTableHeaderProps = { data: OnyxTypes.SearchResults['data']; metadata: OnyxTypes.SearchResults['search']; @@ -102,6 +109,10 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, onSortPress, shou const {translate} = useLocalize(); const displayNarrowVersion = isMediumScreenWidth || isSmallScreenWidth; + if (SearchColumns[metadata.type] === null) { + return; + } + if (displayNarrowVersion) { return; } @@ -109,7 +120,7 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, onSortPress, shou return ( - {expenseHeaders.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { + {SearchColumns[metadata.type]?.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { if (!shouldShow(data, metadata)) { return null; } diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index c484a59fee78..ea0fb35932dd 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -5,10 +5,11 @@ import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; import type CursorStyles from '@styles/utils/cursor/types'; import type CONST from '@src/CONST'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {SearchPersonalDetails, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; +import type {SearchPersonalDetails, SearchReport, SearchReportAction, SearchTransaction} from '@src/types/onyx/SearchResults'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type IconAsset from '@src/types/utils/IconAsset'; +import type ChatListItem from './ChatListItem'; import type InviteMemberListItem from './InviteMemberListItem'; import type RadioListItem from './RadioListItem'; import type ReportListItem from './Search/ReportListItem'; @@ -206,6 +207,21 @@ type TransactionListItemType = ListItem & keyForList: string; }; +type ReportActionListItemType = ListItem & + SearchReportAction & { + /** The personal details of the user posting comment */ + from: SearchPersonalDetails; + + /** final and formatted "from" value used for displaying and sorting */ + formattedFrom: string; + + /** final "date" value used for sorting */ + date: string; + + /** Key used internally by React */ + keyForList: string; + }; + type ReportListItemType = ListItem & SearchReport & { /** The personal details of the user requesting money */ @@ -277,7 +293,16 @@ type TransactionListItemProps = ListItemProps; type ReportListItemProps = ListItemProps; -type ValidListItem = typeof RadioListItem | typeof UserListItem | typeof TableListItem | typeof InviteMemberListItem | typeof TransactionListItem | typeof ReportListItem; +type ChatListItemProps = ListItemProps; + +type ValidListItem = + | typeof RadioListItem + | typeof UserListItem + | typeof TableListItem + | typeof InviteMemberListItem + | typeof TransactionListItem + | typeof ReportListItem + | typeof ChatListItem; type Section = { /** Title of the section */ @@ -556,4 +581,6 @@ export type { TransactionListItemType, UserListItemProps, ValidListItem, + ReportActionListItemType, + ChatListItemProps, }; diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx index ca34e579a431..2d218bc815fe 100644 --- a/src/components/SelectionListWithModal/index.tsx +++ b/src/components/SelectionListWithModal/index.tsx @@ -23,13 +23,15 @@ function SelectionListWithModal( const [isModalVisible, setIsModalVisible] = useState(false); const [longPressedItem, setLongPressedItem] = useState(null); const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout here because there is a race condition that causes shouldUseNarrowLayout to change indefinitely in this component + // See https://github.com/Expensify/App/issues/48675 for more details + const {isSmallScreenWidth} = useResponsiveLayout(); const {selectionMode} = useMobileSelectionMode(true); useEffect(() => { // We can access 0 index safely as we are not displaying multiple sections in table view const selectedItems = sections[0].data.filter((item) => item.isSelected); - if (!shouldUseNarrowLayout) { + if (!isSmallScreenWidth) { if (selectedItems.length === 0) { turnOffMobileSelectionMode(); } @@ -38,11 +40,11 @@ function SelectionListWithModal( if (selectedItems.length > 0 && !selectionMode?.isEnabled) { turnOnMobileSelectionMode(); } - }, [sections, selectionMode, shouldUseNarrowLayout]); + }, [sections, selectionMode, isSmallScreenWidth]); const handleLongPressRow = (item: TItem) => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (!turnOnSelectionModeOnLongPress || !shouldUseNarrowLayout || item?.isDisabled || item?.isDisabledCheckbox) { + if (!turnOnSelectionModeOnLongPress || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox) { return; } setLongPressedItem(item); diff --git a/src/components/SelectionScreen.tsx b/src/components/SelectionScreen.tsx index 8f6ccbf64c81..b86084421fac 100644 --- a/src/components/SelectionScreen.tsx +++ b/src/components/SelectionScreen.tsx @@ -145,6 +145,7 @@ function SelectionScreen({ pendingAction={pendingAction} style={[styles.flex1]} contentContainerStyle={[styles.flex1]} + shouldDisableOpacity={!sections.length} > { + const buttonOptions = []; const isExpenseReport = ReportUtils.isExpenseReport(iouReport); const paymentMethods = { + [CONST.IOU.PAYMENT_TYPE.EXPENSIFY]: { + text: translate('iou.settleExpensify', {formattedAmount}), + icon: Expensicons.Wallet, + value: CONST.IOU.PAYMENT_TYPE.EXPENSIFY, + }, [CONST.IOU.PAYMENT_TYPE.VBBA]: { text: translate('iou.settleExpensify', {formattedAmount}), icon: Expensicons.Wallet, value: CONST.IOU.PAYMENT_TYPE.VBBA, }, - [CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT]: { - text: translate('iou.settlePersonalBank', {formattedAmount}), - icon: Expensicons.Bank, - value: CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT, - }, - [CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT]: { - text: translate('iou.settleBusinessBank', {formattedAmount}), - icon: Expensicons.Bank, - value: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, - }, - [CONST.PAYMENT_METHODS.DEBIT_CARD]: { - text: translate('iou.settleDebitCard', {formattedAmount}), - icon: Expensicons.CreditCard, - value: CONST.PAYMENT_METHODS.DEBIT_CARD, - }, [CONST.IOU.PAYMENT_TYPE.ELSEWHERE]: { text: translate('iou.payElsewhere', {formattedAmount}), icon: Expensicons.Cash, value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, }, }; - const buttonOptions = []; const approveButtonOption = { text: translate('iou.approve'), icon: Expensicons.ThumbsUp, @@ -212,10 +206,12 @@ function SettlementButton({ // If the user has previously chosen a specific payment option or paid for some expense, // let's use the last payment method or use default. const paymentMethod = nvpLastPaymentMethod?.[policyID] ?? '-1'; - if (canUseWallet || (isExpenseReport && shouldShowPaywithExpensifyOption)) { - buttonOptions.push(paymentMethods[CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT]); + if (canUseWallet) { + buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.EXPENSIFY]); + } + if (isExpenseReport && shouldShowPaywithExpensifyOption) { + buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.VBBA]); } - if (shouldShowPayElsewhereOption) { buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]); } @@ -275,12 +271,7 @@ function SettlementButton({ return; } - if ( - iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA || - iouPaymentType === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT || - iouPaymentType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT || - iouPaymentType === CONST.PAYMENT_METHODS.DEBIT_CARD - ) { + if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { triggerKYCFlow(event, iouPaymentType); BankAccounts.setPersonalBankAccountContinueKYCOnSuccess(ROUTES.ENABLE_PAYMENTS); return; @@ -314,6 +305,7 @@ function SettlementButton({ chatReportID={chatReportID} iouReport={iouReport} anchorAlignment={kycWallAnchorAlignment} + shouldShowPersonalBankAccountOption={shouldShowPersonalBankAccountOption} > {(triggerKYCFlow, buttonRef) => ( @@ -321,7 +313,10 @@ function SettlementButton({ onOptionsMenuShow={onPaymentOptionsShow} onOptionsMenuHide={onPaymentOptionsHide} buttonRef={buttonRef} + shouldAlwaysShowDropdownMenu={isInvoiceReport} + customText={isInvoiceReport ? translate('iou.settlePayment', {formattedAmount}) : undefined} menuHeaderText={isInvoiceReport ? translate('workspace.invoices.paymentMethods.chooseInvoiceMethod') : undefined} + isSplitButton={!isInvoiceReport} isDisabled={isDisabled} isLoading={isLoading} onPress={(event, iouPaymentType) => selectPaymentType(event, iouPaymentType, triggerKYCFlow)} diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index a20dc353394e..fe6a2e86bc00 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -1,4 +1,5 @@ import React, {useEffect, useMemo} from 'react'; +import useDebouncedState from '@hooks/useDebouncedState'; import useThemePreferenceWithStaticOverride from '@hooks/useThemePreferenceWithStaticOverride'; import DomUtils from '@libs/DomUtils'; // eslint-disable-next-line no-restricted-imports @@ -12,8 +13,13 @@ type ThemeProviderProps = React.PropsWithChildren & { function ThemeProvider({children, theme: staticThemePreference}: ThemeProviderProps) { const themePreference = useThemePreferenceWithStaticOverride(staticThemePreference); + const [, debouncedTheme, setDebouncedTheme] = useDebouncedState(themePreference); - const theme = useMemo(() => themes[themePreference], [themePreference]); + useEffect(() => { + setDebouncedTheme(themePreference); + }, [setDebouncedTheme, themePreference]); + + const theme = useMemo(() => themes[debouncedTheme], [debouncedTheme]); useEffect(() => { DomUtils.addCSS(DomUtils.getAutofilledInputStyle(theme.text), 'autofill-input'); diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx new file mode 100644 index 000000000000..247c0c606901 --- /dev/null +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -0,0 +1,257 @@ +import {useFocusEffect} from '@react-navigation/native'; +import type {ForwardedRef} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import MagicCodeInput from '@components/MagicCodeInput'; +import type {AutoCompleteVariant, MagicCodeInputHandle} from '@components/MagicCodeInput'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import * as User from '@userActions/User'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Account, ValidateMagicCodeAction} from '@src/types/onyx'; +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type ValidateCodeFormHandle = { + focus: () => void; + focusLastSelected: () => void; +}; + +type ValidateCodeFormError = { + validateCode?: TranslationPaths; +}; + +type BaseValidateCodeFormOnyxProps = { + /** The details about the account that the user is signing in with */ + account: OnyxEntry; +}; + +type ValidateCodeFormProps = { + /** If the magic code has been resent previously */ + hasMagicCodeBeenSent?: boolean; + + /** Specifies autocomplete hints for the system, so it can provide autofill */ + autoComplete?: AutoCompleteVariant; + + /** Forwarded inner ref */ + innerRef?: ForwardedRef; + + /** The state of magic code that being sent */ + validateCodeAction?: ValidateMagicCodeAction; + + /** The pending action for submitting form */ + validatePendingAction?: PendingAction | null; + + /** The error of submitting */ + validateError?: Errors; + + /** Function is called when submitting form */ + handleSubmitForm: (validateCode: string) => void; + + /** Function to clear error of the form */ + clearError: () => void; +}; + +type BaseValidateCodeFormProps = BaseValidateCodeFormOnyxProps & ValidateCodeFormProps; + +function BaseValidateCodeForm({ + account = {}, + hasMagicCodeBeenSent, + autoComplete = 'one-time-code', + innerRef = () => {}, + validateCodeAction, + validatePendingAction, + validateError, + handleSubmitForm, + clearError, +}: BaseValidateCodeFormProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const [formError, setFormError] = useState({}); + const [validateCode, setValidateCode] = useState(''); + const inputValidateCodeRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case + const shouldDisableResendValidateCode = !!isOffline || account?.isLoading; + const focusTimeoutRef = useRef(null); + + useImperativeHandle(innerRef, () => ({ + focus() { + inputValidateCodeRef.current?.focus(); + }, + focusLastSelected() { + if (!inputValidateCodeRef.current) { + return; + } + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + focusTimeoutRef.current = setTimeout(() => { + inputValidateCodeRef.current?.focusLastSelected(); + }, CONST.ANIMATED_TRANSITION); + }, + })); + + useFocusEffect( + useCallback(() => { + if (!inputValidateCodeRef.current) { + return; + } + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + focusTimeoutRef.current = setTimeout(() => { + inputValidateCodeRef.current?.focusLastSelected(); + }, CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, []), + ); + + useEffect(() => { + if (!validateError) { + return; + } + clearError(); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [clearError, validateError]); + + useEffect(() => { + if (!hasMagicCodeBeenSent) { + return; + } + inputValidateCodeRef.current?.clear(); + }, [hasMagicCodeBeenSent]); + + /** + * Request a validate code / magic code be sent to verify this contact method + */ + const resendValidateCode = () => { + User.requestValidateCodeAction(); + inputValidateCodeRef.current?.clear(); + }; + + /** + * Handle text input and clear formError upon text change + */ + const onTextInput = useCallback( + (text: string) => { + setValidateCode(text); + setFormError({}); + + if (validateError) { + clearError(); + User.clearValidateCodeActionError('actionVerified'); + } + }, + [validateError, clearError], + ); + + /** + * Check that all the form fields are valid, then trigger the submit callback + */ + const validateAndSubmitForm = useCallback(() => { + if (!validateCode.trim()) { + setFormError({validateCode: 'validateCodeForm.error.pleaseFillMagicCode'}); + return; + } + + if (!ValidationUtils.isValidValidateCode(validateCode)) { + setFormError({validateCode: 'validateCodeForm.error.incorrectMagicCode'}); + return; + } + + setFormError({}); + handleSubmitForm(validateCode); + }, [validateCode, handleSubmitForm]); + + return ( + <> + + User.clearValidateCodeActionError('actionVerified')} + > + + + {translate('validateCodeForm.magicCodeNotReceived')} + + {hasMagicCodeBeenSent && ( + + )} + + + clearError()} + > +