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..e18cbe15fc6b 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,457 @@ 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 + 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 + 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 + path: ./desktop/dist/www/merged-source-map.js.map + + - name: Upload desktop build artifact + uses: actions/upload-artifact@v4 + with: + name: desktop-build + 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 + path: ./main.jsbundle.map + + - name: Upload iOS build artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: ios-build + 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 + 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 + path: ./webBuild.tar.gz + + - name: Upload .zip web build artifact + uses: actions/upload-artifact@v4 + with: + name: web-build-zip + 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: Download all workflow run artifacts + uses: actions/download-artifact@v4 - - name: 🚀 Create prerelease to trigger staging deploy 🚀 + - name: 🚀 Create prerelease 🚀 run: gh release create ${{ env.STAGING_VERSION }} --title ${{ env.STAGING_VERSION }} --generate-notes --prerelease --target staging env: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} + + - name: Upload artifacts to GitHub Release + run: | + gh release upload ${{ env.STAGING_VERSION }} \ + ./android-sourcemaps/index.android.bundle.map#android-sourcemap-${{ env.STAGING_VERSION }} \ + ./android-build/app-production-release.aab \ + ./desktop-sourcemaps/merged-source-map.js.map#desktop-sourcemap-${{ env.STAGING_VERSION }} \ + ./desktop-build/NewExpensify.dmg \ + ./ios-sourcemaps/main.jsbundle.map#ios-sourcemap-${{ env.STAGING_VERSION }} \ + ./ios-build/New\ Expensify.ipa \ + ./web-sourcemaps/merged-source-map.js.map#web-sourcemap-${{ env.STAGING_VERSION }} \ + ./web-build-tar-gz/webBuild.tar.gz \ + ./web-build-zip/webBuild.zip + env: + GITHUB_TOKEN: ${{ github.token }} - name: Warn deployers if staging deploy failed if: ${{ failure() }} @@ -49,34 +516,35 @@ 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: 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: Upload artifacts to GitHub Release + run: | + gh release upload ${{ env.STAGING_VERSION }} \ + ./desktop-sourcemaps/merged-source-map.js.map#desktop-sourcemap-${{ env.STAGING_VERSION }} \ + ./desktop-build/NewExpensify.dmg \ + ./web-sourcemaps/merged-source-map.js.map#web-sourcemap-${{ env.STAGING_VERSION }} \ + ./web-build-tar-gz/webBuild.tar.gz \ + ./web-build-zip/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 +563,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..e748b5af89cd 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 1009003006 + versionName "9.0.30-6" // 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/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index f676f248fb8c..d02d8b2397a1 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.6 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 5eb312548f35..631a3ab1d031 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.6 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index c13e6f18dc1e..ade1dc089435 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.6 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index e46eb0494ea7..99a5892a8b3a 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-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.29-6", + "version": "9.0.30-6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b36985b72631..58ca057efee4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.29-6", + "version": "9.0.30-6", "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.", diff --git a/src/CONST.ts b/src/CONST.ts index 0f0105fdc358..e2a1e79ccbb3 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5339,6 +5339,11 @@ const CONST = { DONE: 'done', PAID: 'paid', }, + CHAT_STATUS: { + UNREAD: 'unread', + PINNED: 'pinned', + DRAFT: 'draft', + }, BULK_ACTION_TYPES: { EXPORT: 'export', HOLD: 'hold', @@ -5427,6 +5432,7 @@ const CONST = { KEYWORD: 'keyword', IN: 'in', HAS: 'has', + IS: 'is', }, }, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 89023063ad8f..f67298828d7d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -54,6 +54,7 @@ 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', @@ -704,7 +705,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', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index db790dd389c3..b66627153ea8 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', }, 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/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/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/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/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 8eda292a6ede..20aa62c2a951 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -843,6 +843,7 @@ function MoneyRequestConfirmationList({ onPress={confirm} enablePaymentsRoute={ROUTES.IOU_SEND_ENABLE_PAYMENTS} addBankAccountRoute={bankAccountRoute} + shouldShowPersonalBankAccountOption currency={iouCurrencyCode} policyID={policyID} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} 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/SettlementButton.tsx b/src/components/SettlementButton.tsx index fc72f2fe7418..e68d09375d49 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -105,6 +105,9 @@ type SettlementButtonProps = SettlementButtonOnyxProps & { /** The anchor alignment of the popover menu for KYC wall popover */ kycWallAnchorAlignment?: AnchorAlignment; + /** Whether the personal bank account option should be shown */ + shouldShowPersonalBankAccountOption?: boolean; + /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */ enterKeyEventListenerPriority?: number; @@ -144,6 +147,7 @@ function SettlementButton({ shouldShowApproveButton = false, shouldDisableApproveButton = false, style, + shouldShowPersonalBankAccountOption = false, enterKeyEventListenerPriority = 0, confirmApproval, policy, @@ -166,35 +170,25 @@ function SettlementButton({ (!shouldHidePaymentOptions && ReportUtils.isPayer(session, iouReport) && policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL); const shouldShowPayElsewhereOption = (!isPaidGroupPolicy || policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL) && !isInvoiceReport; const paymentButtonOptions = useMemo(() => { + 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/languages/en.ts b/src/languages/en.ts index 7a745bbd00c0..c2902dc3d8a5 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -804,9 +804,6 @@ export default { settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pay ${formattedAmount}`, settleBusiness: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} as a business` : `Pay as a business`), payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} elsewhere` : `Pay elsewhere`), - settlePersonalBank: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with personal bank account` : `Pay with personal bank account`), - settleBusinessBank: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with business bank account` : `Pay with business bank account`), - settleDebitCard: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with debit card` : `Pay with debit card`), nextStep: 'Next steps', finished: 'Finished', sendInvoice: ({amount}: RequestAmountParams) => `Send ${amount} invoice`, @@ -3885,6 +3882,10 @@ export default { currency: 'Currency', has: 'Has', link: 'Link', + is: 'Is', + pinned: 'Pinned', + unread: 'Unread', + draft: 'Draft', amount: { lessThan: (amount?: string) => `Less than ${amount ?? ''}`, greaterThan: (amount?: string) => `Greater than ${amount ?? ''}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index ecdd1f5f4c04..9ce98e12b135 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -797,11 +797,6 @@ export default { settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pagar ${formattedAmount}`, settleBusiness: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} como negocio` : `Pagar como empresa`), payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} de otra forma` : `Pagar de otra forma`), - settlePersonalBank: ({formattedAmount}: SettleExpensifyCardParams) => - formattedAmount ? `Pagar ${formattedAmount} con cuenta bancaria personal` : `Pagar con cuenta bancaria personal`, - settleBusinessBank: ({formattedAmount}: SettleExpensifyCardParams) => - formattedAmount ? `Pagar ${formattedAmount} con cuenta bancaria comercial` : `Pagar con cuenta bancaria comercial`, - settleDebitCard: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} con tarjeta de débito` : `Pagar con tarjeta de débito`), nextStep: 'Pasos siguientes', finished: 'Finalizado', sendInvoice: ({amount}: RequestAmountParams) => `Enviar factura de ${amount}`, @@ -3938,6 +3933,10 @@ export default { currency: 'Divisa', has: 'Tiene', link: 'Enlace', + is: 'Está', + pinned: 'Fijado', + unread: 'No leído', + draft: 'Borrador', amount: { lessThan: (amount?: string) => `Menos de ${amount ?? ''}`, greaterThan: (amount?: string) => `Más que ${amount ?? ''}`, diff --git a/src/languages/types.ts b/src/languages/types.ts index f953cb17255b..b79eb033213e 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -103,7 +103,6 @@ type RequestCountParams = { type SettleExpensifyCardParams = { formattedAmount: string; - available?: boolean; }; type RequestAmountParams = {amount: string}; diff --git a/src/libs/API/parameters/TrackExpenseParams.ts b/src/libs/API/parameters/TrackExpenseParams.ts index d5e1de2e625b..3a7d0df6736c 100644 --- a/src/libs/API/parameters/TrackExpenseParams.ts +++ b/src/libs/API/parameters/TrackExpenseParams.ts @@ -27,6 +27,7 @@ type TrackExpenseParams = { createdReportActionIDForThread: string; waypoints?: string; actionableWhisperReportActionID?: string; + customUnitRateID?: string; }; export default TrackExpenseParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index c2a30f20ed56..e103e81c47d3 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -541,6 +541,7 @@ const SearchAdvancedFiltersModalStackNavigator = createModalStackNavigator require('@pages/Search/SearchAdvancedFiltersPage/SearchFiltersToPage').default, [SCREENS.SEARCH.ADVANCED_FILTERS_IN_RHP]: () => require('@pages/Search/SearchAdvancedFiltersPage/SearchFiltersInPage').default, [SCREENS.SEARCH.ADVANCED_FILTERS_HAS_RHP]: () => require('@pages/Search/SearchAdvancedFiltersPage/SearchFiltersHasPage').default, + [SCREENS.SEARCH.ADVANCED_FILTERS_IS_RHP]: () => require('@pages/Search/SearchAdvancedFiltersPage/SearchFiltersIsPage').default, }); const RestrictedActionModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index cf45126bfc04..a0e1b9a25d35 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1070,6 +1070,7 @@ const config: LinkingOptions['config'] = { [SCREENS.SEARCH.ADVANCED_FILTERS_TO_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_TO, [SCREENS.SEARCH.ADVANCED_FILTERS_IN_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_IN, [SCREENS.SEARCH.ADVANCED_FILTERS_HAS_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_HAS, + [SCREENS.SEARCH.ADVANCED_FILTERS_IS_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_IS, }, }, [SCREENS.RIGHT_MODAL.RESTRICTED_ACTION]: { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index acc9d4bdefc5..a32a8f0be5ff 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -177,6 +177,7 @@ type GetOptionsConfig = { includeInvoiceRooms?: boolean; includeDomainEmail?: boolean; action?: IOUAction; + shouldBoldTitleByDefault?: boolean; }; type GetUserToInviteConfig = { @@ -1743,6 +1744,7 @@ function getOptions( includeInvoiceRooms = false, includeDomainEmail = false, action, + shouldBoldTitleByDefault = true, }: GetOptionsConfig, ): Options { if (includeCategories) { @@ -1967,7 +1969,7 @@ function getOptions( } reportOption.isSelected = isReportSelected(reportOption, selectedOptions); - reportOption.isBold = shouldUseBoldText(reportOption); + reportOption.isBold = shouldBoldTitleByDefault || shouldUseBoldText(reportOption); if (action === CONST.IOU.ACTION.CATEGORIZE) { const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${reportOption.policyID}`] ?? {}; @@ -1991,7 +1993,7 @@ function getOptions( if (personalDetailsOptionsToExclude.some((optionToExclude) => optionToExclude.login === personalDetailOption.login)) { continue; } - personalDetailOption.isBold = shouldUseBoldText(personalDetailOption); + personalDetailOption.isBold = shouldBoldTitleByDefault; personalDetailsOptions.push(personalDetailOption); } @@ -2061,6 +2063,7 @@ function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] = includeMoneyRequests: true, includeTasks: true, includeSelfDM: true, + shouldBoldTitleByDefault: false, }); Timing.end(CONST.TIMING.LOAD_SEARCH_OPTIONS); Performance.markEnd(CONST.TIMING.LOAD_SEARCH_OPTIONS); diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index f9acb67a1efb..a2b1f75c3a2d 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -309,7 +309,7 @@ function getTagNamesFromTagsLists(policyTagLists: PolicyTagLists): string[] { const uniqueTagNames = new Set(); for (const policyTagList of Object.values(policyTagLists ?? {})) { - for (const tag of Object.values(policyTagList.tags)) { + for (const tag of Object.values(policyTagList.tags ?? {})) { uniqueTagNames.add(getCleanedTagName(tag.name)); } } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7b226b2e5c8e..990b83ff2a38 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -55,7 +55,6 @@ import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; import type {Comment, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; -import AccountUtils from './AccountUtils'; import * as IOU from './actions/IOU'; import * as PolicyActions from './actions/Policy/Policy'; import * as store from './actions/ReimbursementAccount/store'; @@ -5981,7 +5980,10 @@ function shouldReportBeInOptionList({ return false; } - if (report?.participants?.[CONST.ACCOUNT_ID.NOTIFICATIONS] && (!currentUserAccountID || !AccountUtils.isAccountIDOddNumber(currentUserAccountID))) { + // We used to use the system DM for A/B testing onboarding tasks, but now only create them in the Concierge chat. We + // still need to allow existing users who have tasks in the system DM to see them, but otherwise we don't need to + // show that chat + if (report?.participants?.[CONST.ACCOUNT_ID.NOTIFICATIONS] && isEmptyReport(report)) { return false; } @@ -7619,8 +7621,9 @@ function shouldShowMerchantColumn(transactions: Transaction[]) { } /** - * Whether the report is a system chat or concierge chat, depending on the onboarding report ID or fallbacking - * to the user's account ID (used for A/B testing purposes). + * Whether a given report is used for onboarding tasks. In the past, it could be either the Concierge chat or the system + * DM, and we saved the report ID in the user's `onboarding` NVP. As a fallback for users who don't have the NVP, we now + * only use the Concierge chat. */ function isChatUsedForOnboarding(optionOrReport: OnyxEntry | OptionData): boolean { // onboarding can be an array for old accounts and accounts created from olddot @@ -7628,13 +7631,12 @@ function isChatUsedForOnboarding(optionOrReport: OnyxEntry | OptionData) return onboarding.chatReportID === optionOrReport?.reportID; } - return AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? -1) - ? isSystemChat(optionOrReport) - : (optionOrReport as OptionData)?.isConciergeChat ?? isConciergeChatReport(optionOrReport); + return (optionOrReport as OptionData)?.isConciergeChat ?? isConciergeChatReport(optionOrReport); } /** - * Get the report (system or concierge chat) used for the user's onboarding process. + * Get the report used for the user's onboarding process. For most users it is the Concierge chat, however in the past + * we also used the system DM for A/B tests. */ function getChatUsedForOnboarding(): OnyxEntry { return Object.values(ReportConnection.getAllReports() ?? {}).find(isChatUsedForOnboarding); diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index a93e3fae8551..4c6c382d6224 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -201,7 +201,8 @@ function peg$parse(input, options) { var peg$c23 = "sortOrder"; var peg$c24 = "policyID"; var peg$c25 = "has"; - var peg$c26 = "\""; + var peg$c26 = "is"; + var peg$c27 = "\""; var peg$r0 = /^[:=]/; var peg$r1 = /^[^"\r\n]/; @@ -235,11 +236,12 @@ function peg$parse(input, options) { var peg$e24 = peg$literalExpectation("sortOrder", false); var peg$e25 = peg$literalExpectation("policyID", false); var peg$e26 = peg$literalExpectation("has", false); - var peg$e27 = peg$literalExpectation("\"", false); - var peg$e28 = peg$classExpectation(["\"", "\r", "\n"], true, false); - var peg$e29 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";"], false, false); - var peg$e30 = peg$otherExpectation("whitespace"); - var peg$e31 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); + var peg$e27 = peg$literalExpectation("is", false); + var peg$e28 = peg$literalExpectation("\"", false); + var peg$e29 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e30 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";"], false, false); + var peg$e31 = peg$otherExpectation("whitespace"); + var peg$e32 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); var peg$f0 = function(filters) { const withDefaults = applyDefaults(filters); @@ -314,10 +316,11 @@ function peg$parse(input, options) { var peg$f27 = function() { return "sortOrder"; }; var peg$f28 = function() { return "policyID"; }; var peg$f29 = function() { return "has"; }; - var peg$f30 = function(parts) { return parts.join(''); }; - var peg$f31 = function(chars) { return chars.join(''); }; + var peg$f30 = function() { return "is"; }; + var peg$f31 = function(parts) { return parts.join(''); }; var peg$f32 = function(chars) { return chars.join(''); }; - var peg$f33 = function() { return "and"; }; + var peg$f33 = function(chars) { return chars.join(''); }; + var peg$f34 = function() { return "and"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -954,6 +957,21 @@ function peg$parse(input, options) { s1 = peg$f29(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c26) { + s1 = peg$c26; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e27); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f30(); + } + s0 = s1; + } } } } @@ -1000,7 +1018,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f30(s1); + s1 = peg$f31(s1); } s0 = s1; @@ -1012,11 +1030,11 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 34) { - s1 = peg$c26; + s1 = peg$c27; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e27); } + if (peg$silentFails === 0) { peg$fail(peg$e28); } } if (s1 !== peg$FAILED) { s2 = []; @@ -1025,7 +1043,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e28); } + if (peg$silentFails === 0) { peg$fail(peg$e29); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -1034,19 +1052,19 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e28); } + if (peg$silentFails === 0) { peg$fail(peg$e29); } } } if (input.charCodeAt(peg$currPos) === 34) { - s3 = peg$c26; + s3 = peg$c27; peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e27); } + if (peg$silentFails === 0) { peg$fail(peg$e28); } } if (s3 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f31(s2); + s0 = peg$f32(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1069,7 +1087,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e29); } + if (peg$silentFails === 0) { peg$fail(peg$e30); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -1079,7 +1097,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e29); } + if (peg$silentFails === 0) { peg$fail(peg$e30); } } } } else { @@ -1087,7 +1105,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f32(s1); + s1 = peg$f33(s1); } s0 = s1; @@ -1100,7 +1118,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f33(); + s1 = peg$f34(); s0 = s1; return s0; @@ -1116,7 +1134,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e31); } + if (peg$silentFails === 0) { peg$fail(peg$e32); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -1125,12 +1143,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e31); } + if (peg$silentFails === 0) { peg$fail(peg$e32); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e30); } + if (peg$silentFails === 0) { peg$fail(peg$e31); } return s0; } diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy index 105a8a62bc39..805ee3e668de 100644 --- a/src/libs/SearchParser/searchParser.peggy +++ b/src/libs/SearchParser/searchParser.peggy @@ -135,6 +135,7 @@ key / "sortOrder" { return "sortOrder"; } / "policyID" { return "policyID"; } / "has" { return "has"; } + / "is" { return "is"; } identifier = parts:(quotedString / alphanumeric)+ { return parts.join(''); } diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 790097597e6a..e0a12e967bfa 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -430,6 +430,18 @@ function getChatFiltersTranslationKey(has: ValueOf): TranslationPaths { + // eslint-disable-next-line default-case + switch (chatStatus) { + case CONST.SEARCH.CHAT_STATUS.PINNED: + return 'search.filters.pinned'; + case CONST.SEARCH.CHAT_STATUS.UNREAD: + return 'search.filters.unread'; + case CONST.SEARCH.CHAT_STATUS.DRAFT: + return 'search.filters.draft'; + } +} + /** * Given object with chosen search filters builds correct query string from them */ @@ -458,7 +470,8 @@ function buildQueryStringFromFilters(filterValues: Partial 0 ) { @@ -605,4 +618,5 @@ export { isCannedSearchQuery, getExpenseTypeTranslationKey, getChatFiltersTranslationKey, + getChatStatusTranslationKey, }; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 29d481737790..874583868381 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -3666,6 +3666,7 @@ function trackExpense( actionableWhisperReportActionID?: string, linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction, linkedTrackedExpenseReportID?: string, + customUnitRateID?: string, ) { const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReportOrDraftReport(report.chatReportID) : report; @@ -3805,6 +3806,7 @@ function trackExpense( transactionThreadReportID: transactionThreadReportID ?? '-1', createdReportActionIDForThread: createdReportActionIDForThread ?? '-1', waypoints: validWaypoints ? JSON.stringify(validWaypoints) : undefined, + customUnitRateID, }; if (actionableWhisperReportActionIDParam) { parameters.actionableWhisperReportActionID = actionableWhisperReportActionIDParam; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 923d23ca9636..247b7363f8a3 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -8,7 +8,6 @@ import Onyx from 'react-native-onyx'; import type {PartialDeep, ValueOf} from 'type-fest'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; -import AccountUtils from '@libs/AccountUtils'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import * as API from '@libs/API'; import type { @@ -2164,17 +2163,6 @@ function navigateToConciergeChat(shouldDismissModal = false, checkIfCurrentPageA } } -/** - * Navigates to the 1:1 system chat - */ -function navigateToSystemChat() { - const systemChatReport = ReportUtils.getSystemChat(); - - if (systemChatReport?.reportID) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(systemChatReport.reportID)); - } -} - /** Add a policy report (workspace room) optimistically and navigate to it. */ function addPolicyReport(policyReport: ReportUtils.OptimisticChatReport) { const createdReportAction = ReportUtils.buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE); @@ -3329,13 +3317,7 @@ function completeOnboarding( adminsChatReportID?: string, onboardingPolicyID?: string, ) { - const isAccountIDOdd = AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? 0); - const targetEmail = isAccountIDOdd ? CONST.EMAIL.NOTIFICATIONS : CONST.EMAIL.CONCIERGE; - - // If the target report isn't opened, the permission field will not exist. So we should add the fallback permission for task report - const fallbackPermission = isAccountIDOdd ? [CONST.REPORT.PERMISSIONS.READ] : [CONST.REPORT.PERMISSIONS.READ, CONST.REPORT.PERMISSIONS.WRITE]; - - const actorAccountID = PersonalDetailsUtils.getAccountIDsByLogins([targetEmail])[0]; + const actorAccountID = CONST.ACCOUNT_ID.CONCIERGE; const targetChatReport = ReportUtils.getChatByParticipants([actorAccountID, currentUserAccountID]); const {reportID: targetChatReportID = '', policyID: targetChatPolicyID = ''} = targetChatReport ?? {}; @@ -3388,7 +3370,7 @@ function completeOnboarding( targetChatPolicyID, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, ); - const taskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(targetEmail); + const taskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(CONST.EMAIL.CONCIERGE); const taskReportAction = ReportUtils.buildOptimisticTaskCommentReportAction( currentTask.reportID, task.title, @@ -3452,7 +3434,6 @@ function completeOnboarding( }, isOptimisticReport: true, managerID: currentUserAccountID, - permissions: targetChatReport?.permissions ?? fallbackPermission, }, }, { @@ -4111,7 +4092,6 @@ export { saveReportActionDraft, deleteReportComment, navigateToConciergeChat, - navigateToSystemChat, addPolicyReport, deleteReport, navigateToConciergeChatAndDeleteReport, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 1d7e695fa2e3..3e057a4745ae 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -52,8 +52,8 @@ import type {HybridAppRoute, Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type Credentials from '@src/types/onyx/Credentials'; -import type {AutoAuthState} from '@src/types/onyx/Session'; import type Session from '@src/types/onyx/Session'; +import type {AutoAuthState} from '@src/types/onyx/Session'; import clearCache from './clearCache'; import updateSessionAuthTokens from './updateSessionAuthTokens'; @@ -420,6 +420,21 @@ function beginSignIn(email: string) { API.read(READ_COMMANDS.BEGIN_SIGNIN, params, {optimisticData, successData, failureData}); } +/** + * Create Onyx update to clean up anonymous user data + */ +function buildOnyxDataToCleanUpAnonymousUser() { + const data: Record = {}; + if (session.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS && session.accountID) { + data[session.accountID] = null; + } + return { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: data, + onyxMethod: Onyx.METHOD.MERGE, + }; +} + /** * Creates an account for the new user and signs them into the application with the newly created account. * @@ -436,6 +451,8 @@ function signUpUser() { }, ]; + const onyxOperationToCleanUpAnonymousUser = buildOnyxDataToCleanUpAnonymousUser(); + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -444,6 +461,7 @@ function signUpUser() { isLoading: false, }, }, + onyxOperationToCleanUpAnonymousUser, ]; const failureData: OnyxUpdate[] = [ @@ -545,6 +563,8 @@ function signIn(validateCode: string, twoFactorAuthCode?: string) { }, ]; + const onyxOperationToCleanUpAnonymousUser = buildOnyxDataToCleanUpAnonymousUser(); + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -561,6 +581,7 @@ function signIn(validateCode: string, twoFactorAuthCode?: string) { validateCode, }, }, + onyxOperationToCleanUpAnonymousUser, ]; const failureData: OnyxUpdate[] = [ @@ -595,6 +616,7 @@ function signInWithValidateCode(accountID: number, code: string, twoFactorAuthCo // If this is called from the 2fa step, get the validateCode directly from onyx // instead of the one passed from the component state because the state is changing when this method is called. const validateCode = twoFactorAuthCode ? credentials.validateCode : code; + const onyxOperationToCleanUpAnonymousUser = buildOnyxDataToCleanUpAnonymousUser(); const optimisticData: OnyxUpdate[] = [ { @@ -635,6 +657,7 @@ function signInWithValidateCode(accountID: number, code: string, twoFactorAuthCo key: ONYXKEYS.SESSION, value: {autoAuthState: CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN}, }, + onyxOperationToCleanUpAnonymousUser, ]; const failureData: OnyxUpdate[] = [ diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index d2dbd7eff953..4579059c23bc 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -6,7 +6,6 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OfflineIndicator from '@components/OfflineIndicator'; -import {useSession} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -15,7 +14,6 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import AccountUtils from '@libs/AccountUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ValidationUtils from '@libs/ValidationUtils'; @@ -40,7 +38,6 @@ function BaseOnboardingPersonalDetails({ const {shouldUseNarrowLayout, onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); const {inputCallbackRef} = useAutoFocusInput(); const [shouldValidateOnChange, setShouldValidateOnChange] = useState(false); - const {accountID} = useSession(); useEffect(() => { Welcome.setOnboardingErrorMessage(''); @@ -76,14 +73,10 @@ function BaseOnboardingPersonalDetails({ // Only navigate to concierge chat when central pane is visible // Otherwise stay on the chats screen. if (!shouldUseNarrowLayout && !route.params?.backTo) { - if (AccountUtils.isAccountIDOddNumber(accountID ?? 0)) { - Report.navigateToSystemChat(); - } else { - Report.navigateToConciergeChat(); - } + Report.navigateToConciergeChat(); } }, - [onboardingPurposeSelected, onboardingAdminsChatReportID, onboardingPolicyID, shouldUseNarrowLayout, route.params?.backTo, accountID], + [onboardingPurposeSelected, onboardingAdminsChatReportID, onboardingPolicyID, shouldUseNarrowLayout, route.params?.backTo], ); const validate = (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 28567cfa8fe5..7775825f33de 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -108,13 +108,18 @@ const baseFilterConfig = { description: 'common.in' as const, route: ROUTES.SEARCH_ADVANCED_FILTERS_IN, }, + is: { + getTitle: getFilterIsDisplayTitle, + description: 'search.filters.is' as const, + route: ROUTES.SEARCH_ADVANCED_FILTERS_IS, + }, }; const typeFiltersKeys: Record>> = { [CONST.SEARCH.DATA_TYPES.EXPENSE]: ['date', 'currency', 'merchant', 'description', 'reportID', 'amount', 'category', 'keyword', 'taxRate', 'expenseType', 'tag', 'from', 'to', 'cardID'], [CONST.SEARCH.DATA_TYPES.INVOICE]: ['date', 'currency', 'merchant', 'description', 'reportID', 'amount', 'category', 'keyword', 'taxRate', 'tag', 'from', 'to', 'cardID'], [CONST.SEARCH.DATA_TYPES.TRIP]: ['date', 'currency', 'merchant', 'description', 'reportID', 'amount', 'category', 'keyword', 'taxRate', 'tag', 'from', 'to', 'cardID'], - [CONST.SEARCH.DATA_TYPES.CHAT]: ['date', 'keyword', 'from', 'has', 'in'], + [CONST.SEARCH.DATA_TYPES.CHAT]: ['date', 'keyword', 'from', 'has', 'in', 'is'], }; function getFilterCardDisplayTitle(filters: Partial, cards: CardList) { @@ -232,6 +237,16 @@ function getFilterHasDisplayTitle(filters: Partial, t : undefined; } +function getFilterIsDisplayTitle(filters: Partial, translate: LocaleContextProps['translate']) { + const filterValue = filters[CONST.SEARCH.SYNTAX_FILTER_KEYS.IS]; + return filterValue + ? Object.values(CONST.SEARCH.CHAT_STATUS) + .filter((chatStatus) => filterValue.includes(chatStatus)) + .map((chatStatus) => translate(SearchUtils.getChatStatusTranslationKey(chatStatus))) + .join(', ') + : undefined; +} + function AdvancedSearchFilters() { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -278,7 +293,7 @@ function AdvancedSearchFilters() { filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters, cardList); } else if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters, taxRates); - } else if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS) { + } else if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.IS) { filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters, translate); } else if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters[key] ?? [], personalDetails); diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersExpenseTypePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersExpenseTypePage.tsx index 65d474b41905..941602f8efe8 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersExpenseTypePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersExpenseTypePage.tsx @@ -65,6 +65,6 @@ function SearchFiltersExpenseTypePage() { ); } -SearchFiltersExpenseTypePage.displayName = 'SearchFiltersCategoryPage'; +SearchFiltersExpenseTypePage.displayName = 'SearchFiltersExpenseTypePage'; export default SearchFiltersExpenseTypePage; diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersIsPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersIsPage.tsx new file mode 100644 index 000000000000..d2431776eb44 --- /dev/null +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersIsPage.tsx @@ -0,0 +1,66 @@ +import React, {useCallback, useMemo} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SearchMultipleSelectionPicker from '@components/Search/SearchMultipleSelectionPicker'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import {getChatStatusTranslationKey} from '@libs/SearchUtils'; +import * as SearchActions from '@userActions/Search'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +function SearchFiltersIsPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const selectedChatStatuses = searchAdvancedFiltersForm?.is?.map((chatStatus) => { + const chatStatusName = translate(getChatStatusTranslationKey(chatStatus as ValueOf)); + return {name: chatStatusName, value: chatStatus}; + }); + const allChatStatuses = Object.values(CONST.SEARCH.CHAT_STATUS); + + const chatStatusItems = useMemo(() => { + return allChatStatuses.map((chatStatus) => { + const chatStatusName = translate(getChatStatusTranslationKey(chatStatus)); + return {name: chatStatusName, value: chatStatus}; + }); + }, [allChatStatuses, translate]); + + const updateChatIsFilter = useCallback((values: string[]) => SearchActions.updateAdvancedFilters({is: values}), []); + + return ( + + { + Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS); + }} + /> + + + + + ); +} + +SearchFiltersIsPage.displayName = 'SearchFiltersIsPage'; + +export default SearchFiltersIsPage; diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTaxRatePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTaxRatePage.tsx index 713e9f5110b2..a1ad32db6308 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTaxRatePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTaxRatePage.tsx @@ -67,6 +67,6 @@ function SearchFiltersTaxRatePage() { ); } -SearchFiltersTaxRatePage.displayName = 'SearchFiltersCategoryPage'; +SearchFiltersTaxRatePage.displayName = 'SearchFiltersTaxRatePage'; export default SearchFiltersTaxRatePage; diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index ff7fb4ff9238..1bbaebc274de 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -722,6 +722,7 @@ function ReportActionItem({ action={action} draftMessage={draftMessage} reportID={report.reportID} + policyID={report.policyID} index={index} ref={textInputRef} shouldDisableEmojiPicker={ diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 15b901689ddc..73bea7060b36 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -61,6 +61,9 @@ type ReportActionItemMessageEditProps = { /** ReportID that holds the comment we're editing */ reportID: string; + /** PolicyID of the policy the report belongs to */ + policyID?: string; + /** Position index of the report action in the overall report FlatList view */ index: number; @@ -68,7 +71,7 @@ type ReportActionItemMessageEditProps = { shouldDisableEmojiPicker?: boolean; /** Whether report is from group policy */ - isGroupPolicyReport?: boolean; + isGroupPolicyReport: boolean; }; // native ids @@ -81,7 +84,7 @@ const shouldUseForcedSelectionRange = shouldUseEmojiPickerSelection(); const draftMessageVideoAttributeCache = new Map(); function ReportActionItemMessageEdit( - {action, draftMessage, reportID, index, isGroupPolicyReport, shouldDisableEmojiPicker = false}: ReportActionItemMessageEditProps, + {action, draftMessage, reportID, policyID, index, isGroupPolicyReport, shouldDisableEmojiPicker = false}: ReportActionItemMessageEditProps, forwardedRef: ForwardedRef, ) { const [preferredSkinTone] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, {initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE}); @@ -532,7 +535,8 @@ function ReportActionItemMessageEdit( isComposerFocused={textInputRef.current?.isFocused()} updateComment={updateDraft} measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} - isGroupPolicyReport={false} + isGroupPolicyReport={isGroupPolicyReport} + policyID={policyID} value={draft} selection={selection} setSelection={setSelection} diff --git a/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx b/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx new file mode 100644 index 000000000000..0192d3d8423a --- /dev/null +++ b/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Avatar from '@components/Avatar'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as UserUtils from '@libs/UserUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ProfileAvatarWithIndicator from './ProfileAvatarWithIndicator'; + +type AvatarWithDelegateAvatarProps = { + /** Emoji status */ + delegateEmail: string; + + /** Whether the avatar is selected */ + isSelected?: boolean; +}; + +function AvatarWithDelegateAvatar({delegateEmail, isSelected = false}: AvatarWithDelegateAvatarProps) { + const styles = useThemeStyles(); + const personalDetails = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const delegatePersonalDetail = Object.values(personalDetails[0] ?? {}).find((personalDetail) => personalDetail?.login?.toLowerCase() === delegateEmail); + + return ( + + + + + + + + + ); +} + +AvatarWithDelegateAvatar.displayName = 'AvatarWithDelegateAvatar'; + +export default AvatarWithDelegateAvatar; diff --git a/src/pages/home/sidebar/BottomTabAvatar.tsx b/src/pages/home/sidebar/BottomTabAvatar.tsx index c928fdbc7dd8..0a798389517b 100644 --- a/src/pages/home/sidebar/BottomTabAvatar.tsx +++ b/src/pages/home/sidebar/BottomTabAvatar.tsx @@ -1,4 +1,5 @@ import React, {useCallback} from 'react'; +import {useOnyx} from 'react-native-onyx'; import {PressableWithFeedback} from '@components/Pressable'; import Tooltip from '@components/Tooltip'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -7,7 +8,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import AvatarWithDelegateAvatar from './AvatarWithDelegateAvatar'; import AvatarWithOptionalStatus from './AvatarWithOptionalStatus'; import ProfileAvatarWithIndicator from './ProfileAvatarWithIndicator'; @@ -22,6 +25,8 @@ type BottomTabAvatarProps = { function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomTabAvatarProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const delegateEmail = account?.delegatedAccess?.delegate ?? ''; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const emojiStatus = currentUserPersonalDetails?.status?.emojiCode ?? ''; @@ -36,7 +41,14 @@ function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomT let children; - if (emojiStatus) { + if (delegateEmail) { + children = ( + + ); + } else if (emojiStatus) { children = ( IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true), true); return; case CONST.QUICK_ACTIONS.SPLIT_DISTANCE: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, true), true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, false), true); return; case CONST.QUICK_ACTIONS.SEND_MONEY: selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.PAY, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true), false); diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index 81de69919ed3..ba406c3ddef6 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -323,6 +323,7 @@ function MoneyRequestAmountForm( horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }} + shouldShowPersonalBankAccountOption enterKeyEventListenerPriority={1} /> ) : ( diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index c43d537c5bfc..812f724907ac 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -303,9 +303,22 @@ function IOURequestStepConfirmation({ transaction.actionableWhisperReportActionID, transaction.linkedTrackedExpenseReportAction, transaction.linkedTrackedExpenseReportID, + customUnitRateID, ); }, - [report, transaction, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, transactionTaxCode, transactionTaxAmount, policy, policyTags, policyCategories, action], + [ + report, + transaction, + currentUserPersonalDetails.login, + currentUserPersonalDetails.accountID, + transactionTaxCode, + transactionTaxAmount, + policy, + policyTags, + policyCategories, + action, + customUnitRateID, + ], ); const createDistanceRequest = useCallback( diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 9131bc4b3d39..38a98017e61a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -118,6 +118,7 @@ function IOURequestStepDistance({ const isCreatingNewRequest = !(backTo || isEditing); const [recentWaypoints, {status: recentWaypointsStatus}] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const iouRequestType = TransactionUtils.getRequestType(transaction); + const customUnitRateID = TransactionUtils.getRateID(transaction); // For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace // request and the workspace requires a category or a tag @@ -293,6 +294,11 @@ function IOURequestStepDistance({ undefined, undefined, TransactionUtils.getValidWaypoints(waypoints, true), + undefined, + undefined, + undefined, + undefined, + customUnitRateID, ); return; } @@ -346,6 +352,7 @@ function IOURequestStepDistance({ policy, iouRequestType, reportNameValuePairs, + customUnitRateID, ]); const getError = () => { diff --git a/src/pages/workspace/accounting/AccountingContext.tsx b/src/pages/workspace/accounting/AccountingContext.tsx index ce5ac90fd2f1..a73287a10e33 100644 --- a/src/pages/workspace/accounting/AccountingContext.tsx +++ b/src/pages/workspace/accounting/AccountingContext.tsx @@ -58,7 +58,15 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider const startIntegrationFlow = React.useCallback( (newActiveIntegration: ActiveIntegration) => { - const accountingIntegrationData = getAccountingIntegrationData(newActiveIntegration.name, policyID, translate); + const accountingIntegrationData = getAccountingIntegrationData( + newActiveIntegration.name, + policyID, + translate, + undefined, + undefined, + newActiveIntegration.integrationToDisconnect, + newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting, + ); const workspaceUpgradeNavigationDetails = accountingIntegrationData?.workspaceUpgradeNavigationDetails; if (workspaceUpgradeNavigationDetails && !isControlPolicy(policy)) { Navigation.navigate( diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index c125052c6935..a8ca3fc2054f 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -1,4 +1,5 @@ -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {useFocusEffect, useRoute} from '@react-navigation/native'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -31,6 +32,7 @@ import { getCurrentXeroOrganizationName, getIntegrationLastSuccessfulDate, getXeroTenants, + isControlPolicy, settingsPendingAction, } from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -40,11 +42,18 @@ import type {AnchorPosition} from '@styles/index'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {ConnectionName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {AccountingContextProvider, useAccountingContext} from './AccountingContext'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; import {getAccountingIntegrationData, getSynchronizationErrorMessage} from './utils'; +type RouteParams = { + newConnectionName?: ConnectionName; + integrationToDisconnect?: ConnectionName; + shouldDisconnectIntegrationBeforeConnecting?: boolean; +}; + function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy.id}`); const theme = useTheme(); @@ -61,6 +70,12 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const {canUseWorkspaceFeeds} = usePermissions(); const {startIntegrationFlow, popoverAnchorRefs} = useAccountingContext(); + const route = useRoute(); + const params = route.params as RouteParams | undefined; + const newConnectionName = params?.newConnectionName; + const integrationToDisconnect = params?.integrationToDisconnect; + const shouldDisconnectIntegrationBeforeConnecting = params?.shouldDisconnectIntegrationBeforeConnecting; + const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); const accountingIntegrations = Object.values(CONST.POLICY.CONNECTIONS.NAME); @@ -113,6 +128,20 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { [shouldShowEnterCredentials, translate, isOffline, policyID, connectedIntegration, startIntegrationFlow], ); + useFocusEffect( + useCallback(() => { + if (!newConnectionName || !isControlPolicy(policy)) { + return; + } + + startIntegrationFlow({ + name: newConnectionName, + integrationToDisconnect, + shouldDisconnectIntegrationBeforeConnecting, + }); + }, [newConnectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting, policy, startIntegrationFlow]), + ); + useEffect(() => { if (successfulDate) { setDateTimeToRelative(getDatetimeToRelative(successfulDate)); @@ -224,7 +253,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { } const shouldShowSynchronizationError = !!synchronizationError; const shouldHideConfigurationOptions = isConnectionUnverified(policy, connectedIntegration); - const integrationData = getAccountingIntegrationData(connectedIntegration, policyID, translate, policy, undefined, canUseNetSuiteUSATax); + const integrationData = getAccountingIntegrationData(connectedIntegration, policyID, translate, policy, undefined, undefined, undefined, canUseNetSuiteUSATax); const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; const configurationOptions = [ diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx index d514c77b2589..e6fc1b3d9624 100644 --- a/src/pages/workspace/accounting/utils.tsx +++ b/src/pages/workspace/accounting/utils.tsx @@ -17,7 +17,7 @@ import {getTrackingCategories} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; -import type {PolicyConnectionName} from '@src/types/onyx/Policy'; +import type {ConnectionName, PolicyConnectionName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import { getImportCustomFieldsSettings, @@ -43,6 +43,8 @@ function getAccountingIntegrationData( translate: LocaleContextProps['translate'], policy?: Policy, key?: number, + integrationToDisconnect?: ConnectionName, + shouldDisconnectIntegrationBeforeConnecting?: boolean, canUseNetSuiteUSATax?: boolean, ): AccountingIntegration | undefined { const qboConfig = policy?.connections?.quickbooksOnline?.config; @@ -189,7 +191,9 @@ function getAccountingIntegrationData( ], workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.netsuite.alias, - backToAfterWorkspaceUpgradeRoute: ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID), + backToAfterWorkspaceUpgradeRoute: integrationToDisconnect + ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) + : ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID), }, pendingFields: {...netsuiteConfig?.pendingFields, ...policy?.connections?.netsuite?.config?.pendingFields}, errorFields: {...netsuiteConfig?.errorFields, ...policy?.connections?.netsuite?.config?.errorFields}, @@ -234,7 +238,9 @@ function getAccountingIntegrationData( ], workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.intacct.alias, - backToAfterWorkspaceUpgradeRoute: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES.getRoute(policyID), + backToAfterWorkspaceUpgradeRoute: integrationToDisconnect + ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) + : ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID), }, pendingFields: policy?.connections?.intacct?.config?.pendingFields, errorFields: policy?.connections?.intacct?.config?.errorFields, diff --git a/src/pages/workspace/categories/ImportedCategoriesPage.tsx b/src/pages/workspace/categories/ImportedCategoriesPage.tsx index 585e5e2833ed..11e309f00c88 100644 --- a/src/pages/workspace/categories/ImportedCategoriesPage.tsx +++ b/src/pages/workspace/categories/ImportedCategoriesPage.tsx @@ -28,6 +28,7 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) { const {containsHeader} = spreadsheet ?? {}; const [isValidationEnabled, setIsValidationEnabled] = useState(false); const policyID = route.params.policyID; + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const policy = usePolicy(policyID); const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); @@ -88,18 +89,22 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) { const categoriesNames = spreadsheet?.data[categoriesNamesColumn].map((name) => name); const categoriesEnabled = categoriesEnabledColumn !== -1 ? spreadsheet?.data[categoriesEnabledColumn].map((enabled) => enabled) : []; const categoriesGLCode = categoriesGLCodeColumn !== -1 ? spreadsheet?.data[categoriesGLCodeColumn].map((glCode) => glCode) : []; - const categories = categoriesNames?.slice(containsHeader ? 1 : 0).map((name, index) => ({ - name, - enabled: categoriesEnabledColumn !== -1 ? categoriesEnabled?.[containsHeader ? index + 1 : index] === 'true' : true, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'GL Code': categoriesGLCodeColumn !== -1 ? categoriesGLCode?.[containsHeader ? index + 1 : index] : '', - })); + const categories = categoriesNames?.slice(containsHeader ? 1 : 0).map((name, index) => { + const categoryAlreadyExists = policyCategories?.[name]; + const existingGLCodeOrDefault = categoryAlreadyExists?.['GL Code'] ?? ''; + return { + name, + enabled: categoriesEnabledColumn !== -1 ? categoriesEnabled?.[containsHeader ? index + 1 : index] === 'true' : true, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': categoriesGLCodeColumn !== -1 ? categoriesGLCode?.[containsHeader ? index + 1 : index] ?? '' : existingGLCodeOrDefault, + }; + }); if (categories) { setIsImportingCategories(true); importPolicyCategories(policyID, categories); } - }, [validate, spreadsheet, containsHeader, policyID]); + }, [validate, spreadsheet, containsHeader, policyID, policyCategories]); const spreadsheetColumns = spreadsheet?.data; if (!spreadsheetColumns) { diff --git a/src/pages/workspace/tags/TagGLCodePage.tsx b/src/pages/workspace/tags/TagGLCodePage.tsx index 54e5c034ca07..89c281e91f4d 100644 --- a/src/pages/workspace/tags/TagGLCodePage.tsx +++ b/src/pages/workspace/tags/TagGLCodePage.tsx @@ -40,15 +40,19 @@ function TagGLCodePage({route, policyTags}: EditTagGLCodePageProps) { const {tags} = PolicyUtils.getTagList(policyTags, orderWeight); const glCode = tags?.[route.params.tagName]?.['GL Code']; + const goBack = useCallback(() => { + Navigation.goBack(ROUTES.WORKSPACE_TAG_SETTINGS.getRoute(route.params.policyID, orderWeight, tagName)); + }, [orderWeight, route.params.policyID, tagName]); + const editGLCode = useCallback( (values: FormOnyxValues) => { const newGLCode = values.glCode.trim(); if (newGLCode !== glCode) { Tag.setPolicyTagGLCode(route.params.policyID, tagName, orderWeight, newGLCode); } - Navigation.goBack(ROUTES.WORKSPACE_TAG_SETTINGS.getRoute(route.params.policyID, orderWeight, tagName)); + goBack(); }, - [glCode, route.params.policyID, tagName, orderWeight], + [glCode, route.params.policyID, tagName, orderWeight, goBack], ); return ( @@ -65,7 +69,7 @@ function TagGLCodePage({route, policyTags}: EditTagGLCodePageProps) { > Navigation.goBack()} + onBackButtonPress={goBack} /> ), [translate, styles.textSupporting, styles.pb10], diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx index 003c7eb00e36..2c69f07ef83b 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -188,6 +188,7 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat subtitle={translate('workflowsPage.emptyContent.expensesFromSubtitle')} subtitleStyle={styles.textSupporting} containerStyle={styles.pb10} + contentFitImage="contain" /> ), [translate, styles.textSupporting, styles.pb10], diff --git a/src/types/form/SearchAdvancedFiltersForm.ts b/src/types/form/SearchAdvancedFiltersForm.ts index 7645d86c5103..7f16c194b8d1 100644 --- a/src/types/form/SearchAdvancedFiltersForm.ts +++ b/src/types/form/SearchAdvancedFiltersForm.ts @@ -22,6 +22,7 @@ const FILTER_KEYS = { TO: 'to', IN: 'in', HAS: 'has', + IS: 'is', } as const; type InputID = ValueOf; @@ -49,6 +50,7 @@ type SearchAdvancedFiltersForm = Form< [FILTER_KEYS.TO]: string[]; [FILTER_KEYS.IN]: string[]; [FILTER_KEYS.HAS]: string[]; + [FILTER_KEYS.IS]: string[]; } >; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index fcab54b470bf..763f221f56fe 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -8,7 +8,7 @@ import type ReportActionName from './ReportActionName'; type JoinWorkspaceResolution = ValueOf; /** Types of payments methods */ -type PaymentMethodType = DeepValueOf; +type PaymentMethodType = DeepValueOf; /** Types of sources of original message */ type OriginalMessageSource = 'Chronos' | 'email' | 'ios' | 'android' | 'web' | ''; diff --git a/tests/e2e/TestSpec.yml b/tests/e2e/TestSpec.yml index 277a4d1ef43b..563a7eb4ce0e 100644 --- a/tests/e2e/TestSpec.yml +++ b/tests/e2e/TestSpec.yml @@ -1,16 +1,15 @@ version: 0.1 +android_test_host: amazon_linux_2 + phases: install: commands: - # Install correct version of node - - export NVM_DIR=$HOME/.nvm - export FLASHLIGHT_BINARY_PATH=$DEVICEFARM_TEST_PACKAGE_PATH/zip/bin - - . $NVM_DIR/nvm.sh # Note: Node v16 is the latest supported version of node for AWS Device Farm # using v20 will not work! - - nvm install 16 - - nvm use --delete-prefix 16 + - devicefarm-cli use node 16 + - node -v # Reverse ports using AWS magic - PORT=4723 diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 6161fa57f75c..bdff4323579f 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -884,7 +884,7 @@ describe('ReportUtils', () => { expect(ReportUtils.isChatUsedForOnboarding(LHNTestUtils.getFakeReport())).toBeFalsy(); }); - it('should return true if the user account ID is odd and report is the system chat', async () => { + it('should return false if the user account ID is odd and report is the system chat - only the Concierge chat chat should be the onboarding chat for users without the onboarding NVP', async () => { const accountID = 1; await Onyx.multiSet({ @@ -901,7 +901,7 @@ describe('ReportUtils', () => { chatType: CONST.REPORT.CHAT_TYPE.SYSTEM, }; - expect(ReportUtils.isChatUsedForOnboarding(report)).toBeTruthy(); + expect(ReportUtils.isChatUsedForOnboarding(report)).toBeFalsy(); }); it('should return true if the user account ID is even and report is the concierge chat', async () => { diff --git a/tests/unit/awaitStagingDeploysTest.ts b/tests/unit/awaitStagingDeploysTest.ts index 64d79c9faf94..26ce2778de40 100644 --- a/tests/unit/awaitStagingDeploysTest.ts +++ b/tests/unit/awaitStagingDeploysTest.ts @@ -33,8 +33,8 @@ type MockedFunctionListResponse = jest.MockedFunction<() => Promise { const defaultReturn = Promise.resolve({data: {workflow_runs: []}}); @@ -44,11 +44,11 @@ const mockListWorkflowRuns = jest.fn().mockImplementation((args: Workflow) => { } if (args.branch !== undefined) { - return mockListPlatformDeploysForTag(); + return mockListDeploysForTag(); } - if (args.workflow_id === 'platformDeploy.yml') { - return mockListPlatformDeploys(); + if (args.workflow_id === 'deploy.yml') { + return mockListDeploys(); } if (args.workflow_id === 'preDeploy.yml') { @@ -89,7 +89,7 @@ describe('awaitStagingDeploys', () => { mockGetInput.mockImplementation(() => undefined); // First ping - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [COMPLETED_WORKFLOW, INCOMPLETE_WORKFLOW, INCOMPLETE_WORKFLOW], }, @@ -101,7 +101,7 @@ describe('awaitStagingDeploys', () => { }); // Second ping - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [COMPLETED_WORKFLOW, COMPLETED_WORKFLOW, INCOMPLETE_WORKFLOW], }, @@ -113,7 +113,7 @@ describe('awaitStagingDeploys', () => { }); // Third ping - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [COMPLETED_WORKFLOW, COMPLETED_WORKFLOW, COMPLETED_WORKFLOW], }, @@ -125,7 +125,7 @@ describe('awaitStagingDeploys', () => { }); // Fourth ping - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [COMPLETED_WORKFLOW, COMPLETED_WORKFLOW, COMPLETED_WORKFLOW], }, @@ -149,12 +149,12 @@ describe('awaitStagingDeploys', () => { mockGetInput.mockImplementation(() => 'my-tag'); // First ping - mockListPlatformDeploysForTag.mockResolvedValueOnce({ + mockListDeploysForTag.mockResolvedValueOnce({ data: { workflow_runs: [INCOMPLETE_WORKFLOW], }, }); - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [INCOMPLETE_WORKFLOW, INCOMPLETE_WORKFLOW], }, @@ -166,12 +166,12 @@ describe('awaitStagingDeploys', () => { }); // Second ping - mockListPlatformDeploysForTag.mockResolvedValueOnce({ + mockListDeploysForTag.mockResolvedValueOnce({ data: { workflow_runs: [INCOMPLETE_WORKFLOW], }, }); - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [INCOMPLETE_WORKFLOW, COMPLETED_WORKFLOW], }, @@ -183,12 +183,12 @@ describe('awaitStagingDeploys', () => { }); // Third ping - mockListPlatformDeploysForTag.mockResolvedValueOnce({ + mockListDeploysForTag.mockResolvedValueOnce({ data: { workflow_runs: [COMPLETED_WORKFLOW], }, }); - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [INCOMPLETE_WORKFLOW, COMPLETED_WORKFLOW, INCOMPLETE_WORKFLOW], },