diff --git a/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts b/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts index 96bb17a14354..e18019144e4e 100644 --- a/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts +++ b/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts @@ -18,7 +18,7 @@ function run() { GitHubUtils.octokit.actions.listWorkflowRuns({ owner: CONST.GITHUB_OWNER, repo: CONST.APP_REPO, - workflow_id: 'platformDeploy.yml', + workflow_id: 'deploy.yml', event: 'push', branch: tag, }), diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index 7bdbafc0b722..561cc980a4e5 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -12138,7 +12138,7 @@ function run() { GithubUtils_1.default.octokit.actions.listWorkflowRuns({ owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, - workflow_id: 'platformDeploy.yml', + workflow_id: 'deploy.yml', event: 'push', branch: tag, }), diff --git a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts index da946b78a056..5d5dbc7e2f29 100644 --- a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts +++ b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts @@ -35,7 +35,7 @@ async function isReleaseValidBaseForEnvironment(releaseTag: string, isProduction } /** - * Was a given platformDeploy workflow run successful on at least one platform? + * Was a given deploy workflow run successful on at least one platform? */ async function wasDeploySuccessful(runID: number) { const jobsForWorkflowRun = ( @@ -82,7 +82,7 @@ async function run() { console.log(`Looking for PRs deployed to ${deployEnv} in ${inputTag}...`); - const completedDeploys = ( + const platformDeploys = ( await GithubUtils.octokit.actions.listWorkflowRuns({ owner: github.context.repo.owner, repo: github.context.repo.repo, @@ -95,6 +95,24 @@ async function run() { // because if a build fails on even one platform, then it will have the status 'failure' .filter((workflowRun) => workflowRun.conclusion !== 'cancelled'); + const deploys = ( + await GithubUtils.octokit.actions.listWorkflowRuns({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + // eslint-disable-next-line @typescript-eslint/naming-convention + workflow_id: 'deploy.yml', + status: 'completed', + }) + ).data.workflow_runs + // Note: we filter out cancelled runs instead of looking only for success runs + // because if a build fails on even one platform, then it will have the status 'failure' + .filter((workflowRun) => workflowRun.conclusion !== 'cancelled'); + + // W've combined platformDeploy.yml and deploy.yml + // TODO: Remove this once there are successful staging and production deploys using the new deploy.yml workflow + const completedDeploys = [...deploys, ...platformDeploys]; + completedDeploys.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + // Find the most recent deploy workflow targeting the correct environment, for which at least one of the build jobs finished successfully let lastSuccessfulDeploy = completedDeploys.shift(); diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index 300cb1edc0ed..3faaeb28f548 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -11526,7 +11526,7 @@ async function isReleaseValidBaseForEnvironment(releaseTag, isProductionDeploy) return !isPrerelease; } /** - * Was a given platformDeploy workflow run successful on at least one platform? + * Was a given deploy workflow run successful on at least one platform? */ async function wasDeploySuccessful(runID) { const jobsForWorkflowRun = (await GithubUtils_1.default.octokit.actions.listJobsForWorkflowRun({ @@ -11566,7 +11566,7 @@ async function run() { const isProductionDeploy = !!(0, ActionUtils_1.getJSONInput)('IS_PRODUCTION_DEPLOY', { required: false }, false); const deployEnv = isProductionDeploy ? 'production' : 'staging'; console.log(`Looking for PRs deployed to ${deployEnv} in ${inputTag}...`); - const completedDeploys = (await GithubUtils_1.default.octokit.actions.listWorkflowRuns({ + const platformDeploys = (await GithubUtils_1.default.octokit.actions.listWorkflowRuns({ owner: github.context.repo.owner, repo: github.context.repo.repo, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -11576,6 +11576,20 @@ async function run() { // Note: we filter out cancelled runs instead of looking only for success runs // because if a build fails on even one platform, then it will have the status 'failure' .filter((workflowRun) => workflowRun.conclusion !== 'cancelled'); + const deploys = (await GithubUtils_1.default.octokit.actions.listWorkflowRuns({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + // eslint-disable-next-line @typescript-eslint/naming-convention + workflow_id: 'deploy.yml', + status: 'completed', + })).data.workflow_runs + // Note: we filter out cancelled runs instead of looking only for success runs + // because if a build fails on even one platform, then it will have the status 'failure' + .filter((workflowRun) => workflowRun.conclusion !== 'cancelled'); + // W've combined platformDeploy.yml and deploy.yml + // TODO: Remove this once there are successful staging and production deploys using the new deploy.yml workflow + const completedDeploys = [...deploys, ...platformDeploys]; + completedDeploys.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); // Find the most recent deploy workflow targeting the correct environment, for which at least one of the build jobs finished successfully let lastSuccessfulDeploy = completedDeploys.shift(); if (!lastSuccessfulDeploy) { diff --git a/.github/scripts/validateActionsAndWorkflows.sh b/.github/scripts/validateActionsAndWorkflows.sh index 07348a302f20..fadb39c88e45 100755 --- a/.github/scripts/validateActionsAndWorkflows.sh +++ b/.github/scripts/validateActionsAndWorkflows.sh @@ -45,7 +45,7 @@ for ((i=0; i < ${#WORKFLOWS[@]}; i++)); do # Skip linting e2e workflow due to bug here: https://github.com/SchemaStore/schemastore/issues/2579 if [[ "$WORKFLOW" == './workflows/e2ePerformanceTests.yml' || "$WORKFLOW" == './workflows/testBuild.yml' - || "$WORKFLOW" == './workflows/platformDeploy.yml' ]]; then + || "$WORKFLOW" == './workflows/deploy.yml' ]]; then continue fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6b1b72f1f901..f2a3f96b8f67 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,15 +4,38 @@ on: push: branches: [staging, production] +env: + SHOULD_DEPLOY_PRODUCTION: ${{ github.ref == 'refs/heads/production' }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - deployStaging: + validateActor: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/staging' + outputs: + IS_DEPLOYER: ${{ fromJSON(steps.isUserDeployer.outputs.IS_DEPLOYER) || github.actor == 'OSBotify' || github.actor == 'os-botify[bot]' }} steps: - - name: Checkout staging branch + - name: Check if user is deployer + id: isUserDeployer + run: | + if gh api /orgs/Expensify/teams/mobile-deployers/memberships/${{ github.actor }} --silent; then + echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT" + else + echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT" + fi + env: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + + createTag: + needs: validateActor + if: ${{ github.ref == 'refs/heads/staging' }} + runs-on: ubuntu-latest + steps: + - name: Checkout uses: actions/checkout@v4 with: - ref: staging token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Setup git for OSBotify @@ -23,13 +46,469 @@ jobs: OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + - name: Create and push tag + run: | + git tag "$(jq -r .version < package.json)" + git push origin --tags + + # Note: we're updating the checklist before running the deploys and assuming that it will succeed on at least one platform + deployChecklist: + name: Create or update deploy checklist + uses: ./.github/workflows/createDeployChecklist.yml + if: ${{ github.ref == 'refs/heads/staging' }} + needs: createTag + secrets: inherit + + android: + # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly + name: Build and deploy Android + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + runs-on: ubuntu-latest-xl + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'oracle' + java-version: '17' + + - name: Setup Ruby + uses: ruby/setup-ruby@v1.190.0 + with: + bundler-cache: true + + - name: Decrypt keystore + run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output my-upload-key.keystore my-upload-key.keystore.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt json key + run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Set version in ENV + run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_ENV" + + - name: Run Fastlane + run: bundle exec fastlane android ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} + env: + RUBYOPT: '-rostruct' + MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} + MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} + VERSION: ${{ env.VERSION_CODE }} + + - name: Upload Android build to Browser Stack + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@./android/app/build/outputs/bundle/productionRelease/app-production-release.aab" + env: + BROWSERSTACK: ${{ secrets.BROWSERSTACK }} + + - name: Upload Android sourcemaps artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: android-sourcemaps-artifact + path: ./android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map + + - name: Upload Android build artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: android-build-artifact + path: ./android/app/build/outputs/bundle/productionRelease/app-production-release.aab + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Warn deployers if Android production deploy failed + if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: "#DB4545", + pretext: ``, + text: `πŸ’₯ Android production deploy failed. Please manually submit ${{ env.VERSION }} in the . πŸ’₯`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + desktop: + # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly + name: Build and deploy Desktop + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + runs-on: macos-14-large + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Decrypt Developer ID Certificate + run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg + env: + DEVELOPER_ID_SECRET_PASSPHRASE: ${{ secrets.DEVELOPER_ID_SECRET_PASSPHRASE }} + + - name: Build desktop app + run: | + if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then + npm run desktop-build + else + npm run desktop-build-staging + fi + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_PRODUCTION }} + + - name: Upload desktop sourcemaps artifact + uses: actions/upload-artifact@v4 + with: + name: desktop-sourcemaps-artifact + path: ./desktop/dist/www/merged-source-map.js.map + + - name: Upload desktop build artifact + uses: actions/upload-artifact@v4 + with: + name: desktop-build-artifact + path: ./desktop-build/NewExpensify.dmg + + iOS: + # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly + name: Build and deploy iOS + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + env: + DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer + runs-on: macos-13-xlarge + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + + - name: Setup Node + id: setup-node + uses: ./.github/actions/composite/setupNode + + - name: Setup Ruby + uses: ruby/setup-ruby@v1.190.0 + with: + bundler-cache: true + + - name: Cache Pod dependencies + uses: actions/cache@v4 + id: pods-cache + with: + path: ios/Pods + key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }} + + - name: Compare Podfile.lock and Manifest.lock + id: compare-podfile-and-manifest + run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" + + - name: Install cocoapods + uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 + if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' + with: + timeout_minutes: 10 + max_attempts: 5 + command: scripts/pod-install.sh + + - name: Decrypt AppStore profile + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt AppStore Notification Service profile + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore_Notification_Service.mobileprovision NewApp_AppStore_Notification_Service.mobileprovision.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt certificate + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt App Store Connect API key + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output ios-fastlane-json-key.json ios-fastlane-json-key.json.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Set iOS version in ENV + run: echo "IOS_VERSION=$(echo '${{ env.VERSION }}' | tr '-' '.')" >> "$GITHUB_ENV" + + - name: Run Fastlane + run: bundle exec fastlane ios ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} + env: + APPLE_CONTACT_EMAIL: ${{ secrets.APPLE_CONTACT_EMAIL }} + APPLE_CONTACT_PHONE: ${{ secrets.APPLE_CONTACT_PHONE }} + APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }} + APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} + VERSION: ${{ env.IOS_VERSION }} + + - name: Upload iOS build to Browser Stack + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/Users/runner/work/App/App/New Expensify.ipa" + env: + BROWSERSTACK: ${{ secrets.BROWSERSTACK }} + + - name: Upload iOS sourcemaps artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: ios-sourcemaps-artifact + path: ./main.jsbundle.map + + - name: Upload iOS build artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: ios-build-artifact + path: /Users/runner/work/App/App/New\ Expensify.ipa + + - name: Warn deployers if iOS production deploy failed + if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: "#DB4545", + pretext: ``, + text: `πŸ’₯ iOS production deploy failed. Please manually submit ${{ env.IOS_VERSION }} in the . πŸ’₯`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + web: + # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly + name: Build and deploy Web + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} + runs-on: ubuntu-latest-xl + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Setup Cloudflare CLI + run: pip3 install cloudflare==2.19.0 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Build web + run: | + if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then + npm run build + else + npm run build-staging + fi + + - name: Build storybook docs + continue-on-error: true + run: | + if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then + npm run storybook-build + else + npm run storybook-build-staging + fi + + - name: Deploy to S3 + run: | + aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist ${{ env.S3_URL }}/ + aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{ env.S3_URL }}/.well-known/apple-app-site-association + aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{env.S3_URL }}/apple-app-site-association + env: + S3_URL: s3://${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging-' || '' }}expensify-cash + + - name: Purge Cloudflare cache + run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging.' || '' }}new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache + env: + CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Verify staging deploy + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: | + sleep 5 + DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://staging.new.expensify.com/version.json | jq -r '.version')" + if [[ '${{ env.VERSION }}' != "$DOWNLOADED_VERSION" ]]; then + echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ env.VERSION }}. Something went wrong..." + exit 1 + fi + + - name: Verify production deploy + if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: | + sleep 5 + DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://new.expensify.com/version.json | jq -r '.version')" + if [[ '${{ env.VERSION }}' != "$DOWNLOADED_VERSION" ]]; then + echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ env.VERSION }}. Something went wrong..." + exit 1 + fi + + - name: Upload web sourcemaps artifact + uses: actions/upload-artifact@v4 + with: + name: web-sourcemaps-artifact + path: ./dist/merged-source-map.js.map + + - name: Compress web build .tar.gz and .zip + run: | + tar -czvf webBuild.tar.gz dist + zip -r webBuild.zip dist + + - name: Upload .tar.gz web build artifact + uses: actions/upload-artifact@v4 + with: + name: web-build-tar-gz-artifact + path: ./webBuild.tar.gz + + - name: Upload .zip web build artifact + uses: actions/upload-artifact@v4 + with: + name: web-build-zip-artifact + path: ./webBuild.zip + + postSlackMessageOnFailure: + name: Post a Slack message when any platform fails to build or deploy + runs-on: ubuntu-latest + if: ${{ failure() }} + needs: [android, desktop, iOS, web] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Post Slack message on failure + uses: ./.github/actions/composite/announceFailedWorkflowInSlack + with: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + + # Build a version of iOS and Android HybridApp if we are deploying to staging + hybridApp: + runs-on: ubuntu-latest + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) && github.ref == 'refs/heads/staging' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: 'Deploy HybridApp' + run: gh workflow run --repo Expensify/Mobile-Deploy deploy.yml -f force_build=true -f build_version="$(npm run print-version --silent)" + env: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + + checkDeploymentSuccess: + runs-on: ubuntu-latest + outputs: + IS_AT_LEAST_ONE_PLATFORM_DEPLOYED: ${{ steps.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED }} + IS_ALL_PLATFORMS_DEPLOYED: ${{ steps.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED }} + needs: [android, desktop, iOS, web] + steps: + - name: Check deployment success on at least one platform + id: checkDeploymentSuccess + run: | + isAtLeastOnePlatformDeployed="false" + isAllPlatformsDeployed="false" + if [ "${{ needs.android.result }}" == "success" ] || \ + [ "${{ needs.iOS.result }}" == "success" ] || \ + [ "${{ needs.desktop.result }}" == "success" ] || \ + [ "${{ needs.web.result }}" == "success" ]; then + isAtLeastOnePlatformDeployed="true" + fi + if [ "${{ needs.android.result }}" == "success" ] && \ + [ "${{ needs.iOS.result }}" == "success" ] && \ + [ "${{ needs.desktop.result }}" == "success" ] && \ + [ "${{ needs.web.result }}" == "success" ]; then + isAllPlatformsDeployed="true" + fi + echo "IS_AT_LEAST_ONE_PLATFORM_DEPLOYED=\"$isAtLeastOnePlatformDeployed\"" >> "$GITHUB_OUTPUT" + echo "IS_ALL_PLATFORMS_DEPLOYED=\"$isAllPlatformsDeployed\"" >> "$GITHUB_OUTPUT" + + createPrerelease: + runs-on: ubuntu-latest + if: ${{ github.ref == 'refs/heads/staging' && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} + needs: [checkDeploymentSuccess] + steps: + - name: Checkout staging branch + uses: actions/checkout@v4 + - name: Get current app version - run: echo "STAGING_VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + run: echo "STAGING_VERSION=$(jq -r .version < package.json)" >> "$GITHUB_ENV" - - name: πŸš€ Create prerelease to trigger staging deploy πŸš€ - run: gh release create ${{ env.STAGING_VERSION }} --title ${{ env.STAGING_VERSION }} --generate-notes --prerelease --target staging + - name: Download all workflow run artifacts + uses: actions/download-artifact@v4 + + - name: πŸš€ Create prerelease πŸš€ + run: | + gh release create ${{ env.STAGING_VERSION }} --title ${{ env.STAGING_VERSION }} --generate-notes --prerelease --target staging + RETRIES=0 + MAX_RETRIES=10 + until [[ $(gh release view ${{ env.STAGING_VERSION }}) || $RETRIES -ge $MAX_RETRIES ]]; do + echo "release not found, retrying $((MAX_RETRIES - RETRIES++)) times" + sleep 1 + done env: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} + + - name: Rename web and desktop sourcemaps artifacts before assets upload in order to have unique ReleaseAsset.name + run: | + mv ./desktop-sourcemaps-artifact/merged-source-map.js.map ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map + mv ./web-sourcemaps-artifact/merged-source-map.js.map ./web-sourcemaps-artifact/web-merged-source-map.js.map + + - name: Upload artifacts to GitHub Release + run: | + gh release upload ${{ env.STAGING_VERSION }} \ + ./android-sourcemaps-artifact/index.android.bundle.map#android-sourcemap-${{ env.STAGING_VERSION }} \ + ./android-build-artifact/app-production-release.aab \ + ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map#desktop-sourcemap-${{ env.STAGING_VERSION }} \ + ./desktop-build-artifact/NewExpensify.dmg \ + ./ios-sourcemaps-artifact/main.jsbundle.map#ios-sourcemap-${{ env.STAGING_VERSION }} \ + ./ios-build-artifact/New\ Expensify.ipa \ + ./web-sourcemaps-artifact/web-merged-source-map.js.map#web-sourcemap-${{ env.STAGING_VERSION }} \ + ./web-build-tar-gz-artifact/webBuild.tar.gz \ + ./web-build-zip-artifact/webBuild.zip + env: + GITHUB_TOKEN: ${{ github.token }} - name: Warn deployers if staging deploy failed if: ${{ failure() }} @@ -49,34 +528,43 @@ jobs: GITHUB_TOKEN: ${{ github.token }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - deployProduction: + finalizeRelease: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/production' + if: ${{ github.ref == 'refs/heads/production' && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} + needs: [checkDeploymentSuccess] steps: - - uses: actions/checkout@v4 - name: Checkout - with: - ref: production - token: ${{ secrets.OS_BOTIFY_TOKEN }} - - - name: Setup git for OSBotify - uses: ./.github/actions/composite/setupGitForOSBotifyApp - id: setupGitForOSBotify - with: - GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} - OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + - name: Checkout production branch + uses: actions/checkout@v4 - name: Get current app version run: echo "PRODUCTION_VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - name: πŸš€ Edit the release to be no longer a prerelease to deploy production πŸš€ + - name: Download all workflow run artifacts + uses: actions/download-artifact@v4 + + - name: Rename web and desktop sourcemaps artifacts before assets upload in order to have unique ReleaseAsset.name + run: | + mv ./desktop-sourcemaps-artifact/merged-source-map.js.map ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map + mv ./web-sourcemaps-artifact/merged-source-map.js.map ./web-sourcemaps-artifact/web-merged-source-map.js.map + + - name: Upload artifacts to GitHub Release + run: | + gh release upload ${{ env.STAGING_VERSION }} \ + ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map#desktop-sourcemap-${{ env.STAGING_VERSION }} \ + ./desktop-build-artifact/NewExpensify.dmg \ + ./web-sourcemaps-artifact/web-merged-source-map.js.map#web-sourcemap-${{ env.STAGING_VERSION }} \ + ./web-build-tar-gz-artifact/webBuild.tar.gz \ + ./web-build-zip-artifact/webBuild.zip + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: πŸš€ Edit the release to be no longer a prerelease πŸš€ run: | LATEST_RELEASE="$(gh release list --exclude-pre-releases --json tagName,isLatest --jq '.[] | select(.isLatest) | .tagName')" - gh api --method POST /repos/Expensify/App/releases/generate-notes -f "tag_name=${{ env.PRODUCTION_VERSION }}" -f "previous_tag_name=$LATEST_RELEASE" >> releaseNotes.md + gh api --method POST /repos/Expensify/App/releases/generate-notes -f "tag_name=${{ env.PRODUCTION_VERSION }}" -f "previous_tag_name=$LATEST_RELEASE" | jq -r '.body' >> releaseNotes.md gh release edit ${{ env.PRODUCTION_VERSION }} --prerelease=false --latest --notes-file releaseNotes.md env: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} - name: Warn deployers if production deploy failed if: ${{ failure() }} @@ -95,3 +583,99 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + postSlackMessageOnSuccess: + name: Post a Slack message when all platforms deploy successfully + runs-on: ubuntu-latest + if: ${{ fromJSON(needs.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED) }} + needs: [checkDeploymentSuccess, createPrerelease, finalizeRelease] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: 'Announces the deploy in the #announce Slack room' + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#announce', + attachments: [{ + color: 'good', + text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + - name: 'Announces the deploy in the #deployer Slack room' + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: 'good', + text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + - name: 'Announces a production deploy in the #expensify-open-source Slack room' + uses: 8398a7/action-slack@v3 + if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + with: + status: custom + custom_payload: | + { + channel: '#expensify-open-source', + attachments: [{ + color: 'good', + text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to production πŸŽ‰οΈ`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + postGithubComment: + name: Post a GitHub comments on all deployed PRs when platforms are done building and deploying + runs-on: ubuntu-latest + if: ${{ fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} + needs: [android, desktop, iOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Get Release Pull Request List + id: getReleasePRList + uses: ./.github/actions/javascript/getDeployPullRequestList + with: + TAG: ${{ env.VERSION }} + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + + - name: Comment on issues + uses: ./.github/actions/javascript/markPullRequestsAsDeployed + with: + PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} + IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + DEPLOY_VERSION: ${{ env.VERSION }} + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + ANDROID: ${{ needs.android.result }} + DESKTOP: ${{ needs.desktop.result }} + IOS: ${{ needs.iOS.result }} + WEB: ${{ needs.web.result }} diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml deleted file mode 100644 index bacab79998f9..000000000000 --- a/.github/workflows/platformDeploy.yml +++ /dev/null @@ -1,487 +0,0 @@ -name: Build and deploy android, desktop, iOS, and web clients - -# This workflow is run when a release or prerelease is created -on: - release: - types: [prereleased, released] - -env: - SHOULD_DEPLOY_PRODUCTION: ${{ github.event.action == 'released' }} - -concurrency: - group: ${{ github.workflow }}-${{ github.event.action }} - cancel-in-progress: true - -jobs: - validateActor: - runs-on: ubuntu-latest - outputs: - IS_DEPLOYER: ${{ fromJSON(steps.isUserDeployer.outputs.IS_DEPLOYER) || github.actor == 'OSBotify' || github.actor == 'os-botify[bot]' }} - steps: - - name: Check if user is deployer - id: isUserDeployer - run: | - if gh api /orgs/Expensify/teams/mobile-deployers/memberships/${{ github.actor }} --silent; then - echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT" - else - echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT" - fi - env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - - # Note: we're updating the checklist before running the deploys and assuming that it will succeed on at least one platform - deployChecklist: - name: Create or update deploy checklist - uses: ./.github/workflows/createDeployChecklist.yml - if: ${{ github.event.action != 'released' }} - needs: validateActor - secrets: inherit - - android: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy Android - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - runs-on: ubuntu-latest-xl - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Configure MapBox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'oracle' - java-version: '17' - - - name: Setup Ruby - uses: ruby/setup-ruby@v1.190.0 - with: - bundler-cache: true - - - name: Decrypt keystore - run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output my-upload-key.keystore my-upload-key.keystore.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt json key - run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Set version in ENV - run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_ENV" - - - name: Run Fastlane - run: bundle exec fastlane android ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} - env: - RUBYOPT: '-rostruct' - MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} - MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} - VERSION: ${{ env.VERSION_CODE }} - - - name: Upload Android build to Browser Stack - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@./android/app/build/outputs/bundle/productionRelease/app-production-release.aab" - env: - BROWSERSTACK: ${{ secrets.BROWSERSTACK }} - - - name: Upload Android sourcemaps to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map#android-sourcemap-${{ github.event.release.tag_name }} - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload Android build to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} android/app/build/outputs/bundle/productionRelease/app-production-release.aab - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Warn deployers if Android production deploy failed - if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: "#DB4545", - pretext: ``, - text: `πŸ’₯ Android production deploy failed. Please manually submit ${{ github.event.release.tag_name }} in the . πŸ’₯`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - desktop: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy Desktop - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - runs-on: macos-14-large - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Decrypt Developer ID Certificate - run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg - env: - DEVELOPER_ID_SECRET_PASSPHRASE: ${{ secrets.DEVELOPER_ID_SECRET_PASSPHRASE }} - - - name: Build desktop app - run: | - if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then - npm run desktop-build - else - npm run desktop-build-staging - fi - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_PRODUCTION }} - - - name: Upload desktop sourcemaps to GitHub Release - run: gh release upload ${{ github.event.release.tag_name }} desktop/dist/www/merged-source-map.js.map#desktop-sourcemap-${{ github.event.release.tag_name }} --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload desktop build to GitHub Release - run: gh release upload ${{ github.event.release.tag_name }} desktop-build/NewExpensify.dmg --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - iOS: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy iOS - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - env: - DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer - runs-on: macos-13-xlarge - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Configure MapBox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - - name: Setup Node - id: setup-node - uses: ./.github/actions/composite/setupNode - - - name: Setup Ruby - uses: ruby/setup-ruby@v1.190.0 - with: - bundler-cache: true - - - name: Cache Pod dependencies - uses: actions/cache@v4 - id: pods-cache - with: - path: ios/Pods - key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }} - - - name: Compare Podfile.lock and Manifest.lock - id: compare-podfile-and-manifest - run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" - - - name: Install cocoapods - uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 - if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' - with: - timeout_minutes: 10 - max_attempts: 5 - command: scripts/pod-install.sh - - - name: Decrypt AppStore profile - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt AppStore Notification Service profile - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore_Notification_Service.mobileprovision NewApp_AppStore_Notification_Service.mobileprovision.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt certificate - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt App Store Connect API key - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output ios-fastlane-json-key.json ios-fastlane-json-key.json.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Set iOS version in ENV - run: echo "IOS_VERSION=$(echo '${{ github.event.release.tag_name }}' | tr '-' '.')" >> "$GITHUB_ENV" - - - name: Run Fastlane - run: bundle exec fastlane ios ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} - env: - APPLE_CONTACT_EMAIL: ${{ secrets.APPLE_CONTACT_EMAIL }} - APPLE_CONTACT_PHONE: ${{ secrets.APPLE_CONTACT_PHONE }} - APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }} - APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} - VERSION: ${{ env.IOS_VERSION }} - - - name: Upload iOS build to Browser Stack - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/Users/runner/work/App/App/New Expensify.ipa" - env: - BROWSERSTACK: ${{ secrets.BROWSERSTACK }} - - - name: Upload iOS sourcemaps to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} main.jsbundle.map#ios-sourcemap-${{ github.event.release.tag_name }} - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload iOS build to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} /Users/runner/work/App/App/New\ Expensify.ipa - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Warn deployers if iOS production deploy failed - if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: "#DB4545", - pretext: ``, - text: `πŸ’₯ iOS production deploy failed. Please manually submit ${{ env.IOS_VERSION }} in the . πŸ’₯`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - web: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy Web - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - runs-on: ubuntu-latest-xl - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Setup Cloudflare CLI - run: pip3 install cloudflare==2.19.0 - - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-1 - - - name: Build web - run: | - if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then - npm run build - else - npm run build-staging - fi - - - name: Build storybook docs - continue-on-error: true - run: | - if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then - npm run storybook-build - else - npm run storybook-build-staging - fi - - - name: Deploy to S3 - run: | - aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist ${{ env.S3_URL }}/ - aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{ env.S3_URL }}/.well-known/apple-app-site-association - aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{env.S3_URL }}/apple-app-site-association - env: - S3_URL: s3://${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging-' || '' }}expensify-cash - - - name: Purge Cloudflare cache - run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging.' || '' }}new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache - env: - CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} - - - name: Verify staging deploy - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: | - sleep 5 - DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://staging.new.expensify.com/version.json | jq -r '.version')" - if [[ '${{ github.event.release.tag_name }}' != "$DOWNLOADED_VERSION" ]]; then - echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ github.event.release.tag_name }}. Something went wrong..." - exit 1 - fi - - - name: Verify production deploy - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: | - sleep 5 - DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://new.expensify.com/version.json | jq -r '.version')" - if [[ '${{ github.event.release.tag_name }}' != "$DOWNLOADED_VERSION" ]]; then - echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ github.event.release.tag_name }}. Something went wrong..." - exit 1 - fi - - - name: Upload web sourcemaps to GitHub Release - run: gh release upload ${{ github.event.release.tag_name }} dist/merged-source-map.js.map#web-sourcemap-${{ github.event.release.tag_name }} --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload web build to GitHub Release - run: | - tar -czvf webBuild.tar.gz dist - zip -r webBuild.zip dist - gh release upload ${{ github.event.release.tag_name }} webBuild.tar.gz webBuild.zip --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - postSlackMessageOnFailure: - name: Post a Slack message when any platform fails to build or deploy - runs-on: ubuntu-latest - if: ${{ failure() }} - needs: [android, desktop, iOS, web] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Post Slack message on failure - uses: ./.github/actions/composite/announceFailedWorkflowInSlack - with: - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} - - # Build a version of iOS and Android HybridApp if we are deploying to staging - hybridApp: - runs-on: ubuntu-latest - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) && github.event.action != 'released' }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: 'Deploy HybridApp' - run: gh workflow run --repo Expensify/Mobile-Deploy deploy.yml -f force_build=true -f build_version="$(npm run print-version --silent)" - env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - - postSlackMessageOnSuccess: - name: Post a Slack message when all platforms deploy successfully - runs-on: ubuntu-latest - if: ${{ success() }} - needs: [android, desktop, iOS, web] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set version - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - - name: 'Announces the deploy in the #announce Slack room' - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#announce', - attachments: [{ - color: 'good', - text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - - name: 'Announces the deploy in the #deployer Slack room' - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: 'good', - text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} πŸŽ‰οΈ`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - - name: 'Announces a production deploy in the #expensify-open-source Slack room' - uses: 8398a7/action-slack@v3 - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - with: - status: custom - custom_payload: | - { - channel: '#expensify-open-source', - attachments: [{ - color: 'good', - text: `πŸŽ‰οΈ Successfully deployed ${process.env.AS_REPO} to production πŸŽ‰οΈ`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - postGithubComment: - name: Post a GitHub comment when platforms are done building and deploying - runs-on: ubuntu-latest - if: ${{ !cancelled() }} - needs: [android, desktop, iOS, web] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Set version - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - - name: Get Release Pull Request List - id: getReleasePRList - uses: ./.github/actions/javascript/getDeployPullRequestList - with: - TAG: ${{ env.VERSION }} - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - - - name: Comment on issues - uses: ./.github/actions/javascript/markPullRequestsAsDeployed - with: - PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} - IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - DEPLOY_VERSION: ${{ env.VERSION }} - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - ANDROID: ${{ needs.android.result }} - DESKTOP: ${{ needs.desktop.result }} - IOS: ${{ needs.iOS.result }} - WEB: ${{ needs.web.result }} diff --git a/android/app/build.gradle b/android/app/build.gradle index 845861443b26..3604cf25d1c1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009002911 - versionName "9.0.29-11" + versionCode 1009003015 + versionName "9.0.30-15" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg b/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg new file mode 100644 index 000000000000..9c0711fcaedc --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md deleted file mode 100644 index b2cfbf833e13..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: Reimburse reports, invoices, and bills -description: Use direct deposit or indirect reimbursement to pay reports, invoices, and bills ---- -
- -Once a report, invoice, or bill has been submitted and approved for reimbursement, you can reimburse the expenses using direct deposit or an indirect reimbursement option outside of Expensify (like cash, a check, or a third-party payment processor). - -# Pay with direct deposit - -{% include info.html %} -Before a report can be reimbursed with direct deposit, the employee or vendor receiving the reimbursement must connect their personal U.S. bank account, and the reimburser must connect a verified business bank account. - -Direct deposit is available for U.S. and global reimbursements. It is not available for Australian bank accounts. For Australian accounts, review the process for reimbursing Australian expenses. -{% include end-info.html %} - -1. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab. -2. Click the **Reimburse** (for reports) or **Pay** (for bills and invoices) dropdown and select **Via Direct Deposit (ACH)**. -3. Confirm that the correct VBA is selected or use the dropdown menu to select a different one. -4. Click **Accept Terms & Pay**. - -The reimbursement is now queued in the daily batch. - -# Pay with indirect reimbursement - -When payments are submitted through Expensify, the report is automatically labeled as Reimbursed after it has been paid. However, if you are reimbursing reports via paper check, payroll, or any other method that takes place outside of Expensify, you’ll want to manually mark the bill as paid in Expensify to track the payment history. - -To label a report as Reimbursed after sending a payment outside of Expensify, - -1. Pay the report, invoice, or bill outside of Expensify. -2. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab. -3. Click **Reimburse**. -4. Select **I’ll do it manually - just mark it as reimbursed**. This changes the report status to Reimbursed. - -Once the recipient has received the payment, the submitter can return to the report and click **Confirm** at the top of the report. This will change the report status to Reimbursed: CONFIRMED. - -{% include faq-begin.md %} - -**Is there a maximum total report total?** - -Expensify cannot process a reimbursement for any single report over $20,000. If you have a report with expenses exceeding $20,000 we recommend splitting the expenses into multiple reports. - -**Why is my account locked?** - -When you reimburse a report, you authorize Expensify to withdraw the funds from your account and send them to the person requesting reimbursement. If your bank rejects Expensify’s withdrawal request, your verified bank account is locked until the issue is resolved. - -Withdrawal requests can be rejected if the bank account has not been enabled for direct debit or due to insufficient funds. If you need to enable direct debits from your verified bank account, your bank will require the following details: -- The ACH CompanyIDs: 1270239450 and 4270239450 -- The ACH Originator Name: Expensify - -Once resolved, you can request to unlock the bank account by completing the following steps: - -1. Hover over **Settings**, then click **Account**. -2. Click the **Payments** tab. -3. Click **Bank Accounts**. -4. Next to the bank account, click **Fix**. - -Our support team will review and process the request within 4-5 business days. - -**How are bills and invoices processed in Expensify?** - -Here is the process a vendor or supplier bill goes through from receipt to payment: - -1. A vendor or supplier bill is received in Expensify. -2. Automatically, the document is SmartScanned and a bill is created for the primary domain contact. The bill will appear under the Reports tab on their default group policy. -3. When the bill is ready for processing, it is submitted and follows the primary domain contact’s approval workflow until the bill has been fully approved. -4. The final approver pays the bill from their Expensify account using one of the methods outlined in the article above. -5. If the workspace is connected to an accounting integration, the bill is automatically coded with the relevant imported GL codes and can be exported back to the accounting software. - -**When a vendor or supplier bill is sent to Expensify, who receives it?** - -Bills are sent to the primary contact for the domain. They’ll see a notification from Concierge on their Home page, and they’ll also receive an email. - -**How can I share access to bills?** - -By default, only the primary contact for the domain can view and pay the bill. However, you can allow someone else to view or pay bills. - -- **To allow someone to view a bill**: The primary contact can manually share the bill with others to allow them to view it. - 1. Click the **Reports** tab. - 2. Click the report. - 3. Click **Details** in the top right. - 4. Click the **Add Person** icon. - 5. Enter the email address or phone number of the person you will share the report with. - 6. Enter a message, if desired. - 7. Click **Share Report**. - -- **To allow someone to pay bills**: The primary domain contact can allow others to pay bills on their behalf by [assigning those individuals as Copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot). - -**Is Bill Pay supported internationally?** - -Payments are currently only supported for users paying in United States Dollars (USD). - -**What’s the difference between a bill and an invoice?** - -- A **bill** is a payable that represents an amount owed to a payee (usually a vendor or supplier), and it is usually created from a vendor invoice. -- An **invoice** is a receivable that indicates an amount owed to you by someone else. - -**Who can reimburse reports?** - -Only a Workspace Admin who has added a verified business bank account to their Expensify account can reimburse employee reports. - -**Why can’t I trigger direct ACH reimbursements in bulk?** - -Expensify does not offer bulk reimbursement, but you can set up automatic reimbursement to automatically reimburse approved reports via ACH that do not exceed the threshold that you define. - -{% include faq-end.md %} - -
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md new file mode 100644 index 000000000000..afe366fb1dbe --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md @@ -0,0 +1,95 @@ +--- +title: Reimburse Reports +description: +--- +
+ +Once a report is submitted and approved, you can reimburse the expenses directly via direct deposit or global reimbursement, use an indirect reimbursement method (such as a third-party payment processor), or mark the report as reimbursed outside of Expensify (if your organization bundles reimbursements in payroll, for instance). + +## Direct Deposit - USD + +Before a report can be reimbursed via direct deposit: +- The reimburser must [connect a verified business bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account) +- The recipient must [connect a personal bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account) + +To reimburse a report via direct deposit (USD): +1. Open the report. +2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**. +3. Confirm that the correct bank account is listed in the dropdown menu. +4. Click **Accept Terms & Pay**. + +If the reimbursement is less than $200, it will typically be deposited into the employee's bank account immediately. If the reimbursement is more than $200, the deposit will be processed within one to five business days. + +## Direct Deposit - Global Reimbursement +Before a report can be reimbursed via global reimbursement: +- A workspace admin must [set up global reimbursements](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements) +- Employees must [connect a deposit account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account) + +To reimburse a report via global reimbursement: +1. Open the report. +2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**. +3. Confirm that the correct bank account is listed in the dropdown menu. +4. Click **Accept Terms & Pay**. + +The reimbursement should be processed within five business days. If the payment hasn't been processed within that timeframe, reach out to Expensify Support for assistance. + +## Indirect Reimbursement +If you are reimbursing reports outside of Expensify via paper check or payroll, you’ll want to manually mark the report as paid to track the payment history. + +To label a report as Reimbursed after sending a payment outside of Expensify: +1. Open the report +2. Click **Reimburse**. +3. Select **I’ll do it manually - just mark it as reimbursed**. This changes the report status to Reimbursed. + +Once the recipient has received the payment, the submitter can return to the report and click **Confirm**. This will change the report status to **`Reimbursed: CONFIRMED`**. + +### Reimburse a report via a third-party payment provider + +If both the reimburser and the payment recipient have Venmo accounts, you can [connect them directly to Expensify](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments) to send and receive reimbursements. + +### Reimburse a report via ABA batch file +Workspace Admins can reimburse AUD expense reports by downloading an ABA file containing the accounts needing payment and uploading the file to the bank. This can be done for a single report or for a batch of payments. + +More information on reimbursing reports via ABA batch file can be found **[here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Australian-Reports)**. + +{% include faq-begin.md %} + +## Is there a maximum report total? + +Expensify cannot process a reimbursement for any single report over $20,000. If you have a report with expenses exceeding $20,000 we recommend splitting the expenses into multiple reports. + +## Why is my business bank account locked? + +When you reimburse a report, you authorize Expensify to withdraw the funds from your account and send them to the person requesting reimbursement. If your bank rejects Expensify’s withdrawal request, your verified bank account is locked until the issue is resolved. + +Withdrawal requests can be rejected if the bank account has not been enabled for direct debit or due to insufficient funds. If you need to enable direct debits from your verified bank account, your bank will require the following details: +- The ACH CompanyIDs: 1270239450 and 4270239450 +- The ACH Originator Name: Expensify + +Once resolved, you can request to unlock the bank account by completing the following steps: +1. Hover over **Settings**, then click **Account**. +2. Click the **Payments** tab. +3. Click **Bank Accounts**. +4. Next to the bank account, click **Fix**. + +Our support team will review and process the request within 4-5 business days. + +## Who can reimburse reports? + +Only a Workspace Admin who has added a verified business bank account connected to their Expensify account can reimburse employee reports. + +## How can I add another employee as a reimburser? + +You can give another employee access to reimburse reports by doing the following: +1. If they're not already a workspace admin, add them as one under **Settings > Workspaces > [Workspace Name] > Members**. +2. Share the business bank account with them by heading to **Settings > Account > Payments** and clicking **Share**. +3. The new reimburser will need to validate the shared bank connection by entering the test deposits that Expensify sends to the bank account. +4. Once validated, the employee will have access to reimburse reports. You can make them the default reimburser for all reports submitted on a specific workspace by selecting them from the dropdown menu under **Settings > Workspaces > [Workspace Name] > Reimbursements > Reimburser**. + +## Why can’t I trigger direct ACH reimbursements in bulk? + +Expensify does not offer bulk reimbursement, but you can automate reimbursements by setting a threshold amount under **Settings > Workspaces > [Workspace Name] > Reimbursement**. After setting a threshold amount, an employee's reimbursement is triggered once a report is **Final Approved**. If the total of a report is more than the threshold amount, the reimbursement will need to be manually triggered. + +{% include faq-end.md %} + +
diff --git a/docs/redirects.csv b/docs/redirects.csv index 480fd4220bd4..256e7f370575 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -569,3 +569,4 @@ https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2 https://community.expensify.com/discussion/5654/deep-dive-using-expense-rules-to-vendor-match-when-exporting-to-an-accounting-package/p1?new=1,https://help.expensify.com/articles/expensify-classic/connections/xero/Xero-Troubleshooting https://help.expensify.com/articles/expensify-classic/spending-insights/(https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates),https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-notifications,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-Notifications +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index e8f80bf19cd0..27d35cd77d3d 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.29 + 9.0.30 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.29.11 + 9.0.30.15 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 481ad2b4455c..8e299efa41e9 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.29 + 9.0.30 CFBundleSignature ???? CFBundleVersion - 9.0.29.11 + 9.0.30.15 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 01df9d9c7e1a..a037eddf9ebf 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.29 + 9.0.30 CFBundleVersion - 9.0.29.11 + 9.0.30.15 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index f73248b653df..29e6be5e5e45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.29-11", + "version": "9.0.30-15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.29-11", + "version": "9.0.30-15", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -96,7 +96,7 @@ "react-native-google-places-autocomplete": "2.5.6", "react-native-haptic-feedback": "^2.2.0", "react-native-image-picker": "^7.0.3", - "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#93399c6410de32966eb57085936ef6951398c2c3", + "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", "react-native-keyboard-controller": "^1.12.2", "react-native-launch-arguments": "^4.0.2", @@ -37314,8 +37314,8 @@ }, "node_modules/react-native-image-size": { "version": "1.1.3", - "resolved": "git+ssh://git@github.com/Expensify/react-native-image-size.git#93399c6410de32966eb57085936ef6951398c2c3", - "integrity": "sha512-hR38DhM3ewEv5VPhyCAbrhgWWlA1Hyys69BdUFkUes2wgiZc2ARVaXoLKuvzYT3g9fNYLwijylaSEs3juDkPKg==" + "resolved": "git+ssh://git@github.com/Expensify/react-native-image-size.git#cb392140db4953a283590d7cf93b4d0461baa2a9", + "integrity": "sha512-kF/8fGsKoOnjPZceipRUaM9Xg9a/aKXU2Vm5eHYEKHrRt8FP39oCbaELPTb/vUKRTu1HmEGffDFzRT02BcdzYQ==" }, "node_modules/react-native-key-command": { "version": "1.0.8", diff --git a/package.json b/package.json index 2caac633ed4a..0b18f04667a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.29-11", + "version": "9.0.30-15", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -153,7 +153,7 @@ "react-native-google-places-autocomplete": "2.5.6", "react-native-haptic-feedback": "^2.2.0", "react-native-image-picker": "^7.0.3", - "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#93399c6410de32966eb57085936ef6951398c2c3", + "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", "react-native-keyboard-controller": "^1.12.2", "react-native-launch-arguments": "^4.0.2", diff --git a/src/CONFIG.ts b/src/CONFIG.ts index a1a72b86fadd..047d4dc823fd 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -97,5 +97,5 @@ export default { }, GCP_GEOLOCATION_API_KEY: googleGeolocationAPIKey, // to read more about StrictMode see: contributingGuides/STRICT_MODE.md - USE_REACT_STRICT_MODE_IN_DEV: true, + USE_REACT_STRICT_MODE_IN_DEV: false, } as const; diff --git a/src/CONST.ts b/src/CONST.ts index e2a1e79ccbb3..d0695b1e285f 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -4,6 +4,7 @@ import dateSubtract from 'date-fns/sub'; import Config from 'react-native-config'; import * as KeyCommand from 'react-native-key-command'; import type {ValueOf} from 'type-fest'; +import type {Video} from './libs/actions/Report'; import BankAccount from './libs/models/BankAccount'; import * as Url from './libs/Url'; import SCREENS from './SCREENS'; @@ -64,16 +65,91 @@ const chatTypes = { // Explicit type annotation is required const cardActiveStates: number[] = [2, 3, 4, 7]; -const onboardingChoices = { +const selectableOnboardingChoices = { PERSONAL_SPEND: 'newDotPersonalSpend', MANAGE_TEAM: 'newDotManageTeam', EMPLOYER: 'newDotEmployer', CHAT_SPLIT: 'newDotSplitChat', LOOKING_AROUND: 'newDotLookingAround', +} as const; + +const backendOnboardingChoices = { + SUBMIT: 'newDotSubmit', +} as const; + +const onboardingChoices = { + ...selectableOnboardingChoices, + ...backendOnboardingChoices, +} as const; + +const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { + message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', + video: { + url: `${CLOUDFRONT_URL}/videos/guided-setup-get-paid-back-v2.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-get-paid-back.jpg`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'submitExpense', + autoCompleted: false, + title: 'Submit an expense', + description: + '*Submit an expense* by entering an amount or scanning a receipt.\n' + + '\n' + + 'Here’s how to submit an expense:\n' + + '\n' + + '1. Click the green *+* button.\n' + + '2. Choose *Submit expense*.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Add your reimburser to the request.\n' + + '\n' + + 'Then, send your request and wait for that sweet β€œCha-ching!” when it’s complete.', + }, + { + type: 'enableWallet', + autoCompleted: false, + title: 'Enable your wallet', + description: + 'You’ll need to *enable your Expensify Wallet* to get paid back. Don’t worry, it’s easy!\n' + + '\n' + + 'Here’s how to set up your wallet:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click *Wallet* > *Enable wallet*.\n' + + '3. Connect your bank account.\n' + + '\n' + + 'Once that’s done, you can request money from anyone and get paid back right into your personal bank account.', + }, + ], }; type OnboardingPurposeType = ValueOf; +const onboardingInviteTypes = { + IOU: 'iou', + INVOICE: 'invoice', + CHAT: 'chat', +} as const; + +type OnboardingInviteType = ValueOf; + +type OnboardingTaskType = { + type: string; + autoCompleted: boolean; + title: string; + description: string | ((params: Partial<{adminsRoomLink: string; workspaceCategoriesLink: string; workspaceMoreFeaturesLink: string; workspaceMembersLink: string}>) => string); +}; + +type OnboardingMessageType = { + message: string; + video?: Video; + tasks: OnboardingTaskType[]; + type?: string; +}; + const CONST = { HEIC_SIGNATURES: [ '6674797068656963', // 'ftypheic' - Indicates standard HEIC file @@ -639,7 +715,7 @@ const CONST = { SAGE_INTACCT_HELP_LINK: "https://help.expensify.com/articles/expensify-classic/connections/sage-intacct/Sage-Intacct-Troubleshooting#:~:text=First%20make%20sure%20that%20you,your%20company's%20Web%20Services%20authorizations.", PRICING: `https://www.expensify.com/pricing`, - + CUSTOM_REPORT_NAME_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates', // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', OLDDOT_URLS: { @@ -804,6 +880,7 @@ const CONST = { UPDATE_AUTO_REPORTING_FREQUENCY: 'POLICYCHANGELOG_UPDATE_AUTOREPORTING_FREQUENCY', UPDATE_BUDGET: 'POLICYCHANGELOG_UPDATE_BUDGET', UPDATE_CATEGORY: 'POLICYCHANGELOG_UPDATE_CATEGORY', + UPDATE_CATEGORIES: 'POLICYCHANGELOG_UPDATE_CATEGORIES', UPDATE_CURRENCY: 'POLICYCHANGELOG_UPDATE_CURRENCY', UPDATE_CUSTOM_UNIT: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT', UPDATE_CUSTOM_UNIT_RATE: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT_RATE', @@ -1262,6 +1339,7 @@ const CONST = { ATTACHMENT_TYPE: { REPORT: 'r', NOTE: 'n', + SEARCH: 's', }, IMAGE_HIGH_RESOLUTION_THRESHOLD: 7000, @@ -1918,6 +1996,11 @@ const CONST = { BUSINESS_BANK_ACCOUNT: 'businessBankAccount', }, + PAYMENT_SELECTED: { + BBA: 'BBA', + PBA: 'PBA', + }, + PAYMENT_METHOD_ID_KEYS: { DEBIT_CARD: 'fundID', BANK_ACCOUNT: 'bankAccountID', @@ -1992,6 +2075,10 @@ const CONST = { ACCESS_VARIANTS: { CREATE: 'create', }, + PAYMENT_SELECTED: { + BBA: 'BBA', + PBA: 'PBA', + }, }, GROWL: { @@ -2054,11 +2141,18 @@ const CONST = { // Often referred to as "collect" workspaces TEAM: 'team', }, + FIELD_LIST_TITLE_FIELD_ID: 'text_title', + DEFAULT_REPORT_NAME_PATTERN: '{report:type} {report:startdate}', ROLE: { ADMIN: 'admin', AUDITOR: 'auditor', USER: 'user', }, + AUTO_REIMBURSEMENT_MAX_LIMIT_CENTS: 2000000, + AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS: 10000, + AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS: 10000, + RANDOM_AUDIT_DEFAULT_PERCENTAGE: 5, + AUTO_REPORTING_FREQUENCIES: { INSTANT: 'instant', IMMEDIATE: 'immediate', @@ -3982,6 +4076,7 @@ const CONST = { SUBMITTER: 'submitter', ALL: 'all', }, + DELEGATE_ROLE_HELPDOT_ARTICLE_LINK: 'https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/', STRIPE_GBP_AUTH_STATUSES: { SUCCEEDED: 'succeeded', CARD_AUTHENTICATION_REQUIRED: 'authentication_required', @@ -4286,6 +4381,8 @@ const CONST = { ONBOARDING_INTRODUCTION: 'Let’s get you set up πŸ”§', ONBOARDING_CHOICES: {...onboardingChoices}, + SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices}, + ONBOARDING_INVITE_TYPES: {...onboardingInviteTypes}, ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE: 'What would you like to do with this expense?', ONBOARDING_CONCIERGE: { [onboardingChoices.EMPLOYER]: @@ -4328,49 +4425,8 @@ const CONST = { }, ONBOARDING_MESSAGES: { - [onboardingChoices.EMPLOYER]: { - message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', - video: { - url: `${CLOUDFRONT_URL}/videos/guided-setup-get-paid-back-v2.mp4`, - thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-get-paid-back.jpg`, - duration: 55, - width: 1280, - height: 960, - }, - tasks: [ - { - type: 'submitExpense', - autoCompleted: false, - title: 'Submit an expense', - description: - '*Submit an expense* by entering an amount or scanning a receipt.\n' + - '\n' + - 'Here’s how to submit an expense:\n' + - '\n' + - '1. Click the green *+* button.\n' + - '2. Choose *Submit expense*.\n' + - '3. Enter an amount or scan a receipt.\n' + - '4. Add your reimburser to the request.\n' + - '\n' + - 'Then, send your request and wait for that sweet β€œCha-ching!” when it’s complete.', - }, - { - type: 'enableWallet', - autoCompleted: false, - title: 'Enable your wallet', - description: - 'You’ll need to *enable your Expensify Wallet* to get paid back. Don’t worry, it’s easy!\n' + - '\n' + - 'Here’s how to set up your wallet:\n' + - '\n' + - '1. Click your profile picture.\n' + - '2. Click *Wallet* > *Enable wallet*.\n' + - '3. Connect your bank account.\n' + - '\n' + - 'Once that’s done, you can request money from anyone and get paid back right into your personal bank account.', - }, - ], - }, + [onboardingChoices.EMPLOYER]: onboardingEmployerOrSubmitMessage, + [onboardingChoices.SUBMIT]: onboardingEmployerOrSubmitMessage, [onboardingChoices.MANAGE_TEAM]: { message: 'Here are some important tasks to help get your team’s expenses under control.', video: { @@ -4399,7 +4455,7 @@ const CONST = { type: 'meetGuide', autoCompleted: false, title: 'Meet your setup specialist', - description: ({adminsRoomLink}: {adminsRoomLink: string}) => + description: ({adminsRoomLink}) => `Meet your setup specialist, who can answer any questions as you get started with Expensify. Yes, a real human!\n` + '\n' + `Chat with the specialist in your [#admins room](${adminsRoomLink}).`, @@ -4408,7 +4464,7 @@ const CONST = { type: 'setupCategories', autoCompleted: false, title: 'Set up categories', - description: ({workspaceCategoriesLink}: {workspaceCategoriesLink: string}) => + description: ({workspaceCategoriesLink}) => '*Set up categories* so your team can code expenses for easy reporting.\n' + '\n' + 'Here’s how to set up categories:\n' + @@ -4427,7 +4483,7 @@ const CONST = { type: 'addExpenseApprovals', autoCompleted: false, title: 'Add expense approvals', - description: ({workspaceMoreFeaturesLink}: {workspaceMoreFeaturesLink: string}) => + description: ({workspaceMoreFeaturesLink}) => '*Add expense approvals* to review your team’s spend and keep it under control.\n' + '\n' + 'Here’s how to add expense approvals:\n' + @@ -4446,7 +4502,7 @@ const CONST = { type: 'inviteTeam', autoCompleted: false, title: 'Invite your team', - description: ({workspaceMembersLink}: {workspaceMembersLink: string}) => + description: ({workspaceMembersLink}) => '*Invite your team* to Expensify so they can start tracking expenses today.\n' + '\n' + 'Here’s how to invite your team:\n' + @@ -4555,7 +4611,7 @@ const CONST = { "Expensify is best known for expense and corporate card management, but we do a lot more than that. Let me know what you're interested in and I'll help get you started.", tasks: [], }, - }, + } satisfies Record, REPORT_FIELD_TITLE_FIELD_ID: 'text_title', @@ -5374,10 +5430,15 @@ const CONST = { }, TRIP: { ALL: 'all', - DRAFTS: 'drafts', - OUTSTANDING: 'outstanding', - APPROVED: 'approved', - PAID: 'paid', + CURRENT: 'current', + PAST: 'past', + }, + CHAT: { + ALL: 'all', + UNREAD: 'unread', + SENT: 'sent', + ATTACHMENTS: 'attachments', + LINKS: 'links', }, }, CHAT_TYPES: { @@ -5633,6 +5694,6 @@ type FeedbackSurveyOptionID = ValueOf; type CancellationType = ValueOf; -export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType}; +export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType, OnboardingInviteType}; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d2a0372fd9c7..b291eda984da 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,5 +1,6 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; +import type {OnboardingPurposeType} from './CONST'; import type * as FormTypes from './types/form'; import type * as OnyxTypes from './types/onyx'; import type Onboarding from './types/onyx/Onboarding'; @@ -336,6 +337,9 @@ const ONYXKEYS = { /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_ADMINS_CHAT_REPORT_ID: 'onboardingAdminsChatReportID', + // Stores onboarding last visited path + ONBOARDING_LAST_VISITED_PATH: 'onboardingLastVisitedPath', + // Max width supported for HTML element MAX_CANVAS_WIDTH: 'maxCanvasWidth', @@ -642,6 +646,14 @@ const ONYXKEYS = { SEARCH_ADVANCED_FILTERS_FORM_DRAFT: 'searchAdvancedFiltersFormDraft', TEXT_PICKER_MODAL_FORM: 'textPickerModalForm', TEXT_PICKER_MODAL_FORM_DRAFT: 'textPickerModalFormDraft', + RULES_CUSTOM_NAME_MODAL_FORM: 'rulesCustomNameModalForm', + RULES_CUSTOM_NAME_MODAL_FORM_DRAFT: 'rulesCustomNameModalFormDraft', + RULES_AUTO_APPROVE_REPORTS_UNDER_MODAL_FORM: 'rulesAutoApproveReportsUnderModalForm', + RULES_AUTO_APPROVE_REPORTS_UNDER_MODAL_FORM_DRAFT: 'rulesAutoApproveReportsUnderModalFormDraft', + RULES_RANDOM_REPORT_AUDIT_MODAL_FORM: 'rulesRandomReportAuditModalForm', + RULES_RANDOM_REPORT_AUDIT_MODAL_FORM_DRAFT: 'rulesRandomReportAuditModalFormDraft', + RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM: 'rulesAutoPayReportsUnderModalForm', + RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM_DRAFT: 'rulesAutoPayReportsUnderModalFormDraft', RULES_REQUIRED_RECEIPT_AMOUNT_FORM: 'rulesRequiredReceiptAmountForm', RULES_REQUIRED_RECEIPT_AMOUNT_FORM_DRAFT: 'rulesRequiredReceiptAmountFormDraft', RULES_MAX_EXPENSE_AMOUNT_FORM: 'rulesMaxExpenseAmountForm', @@ -729,6 +741,10 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.SAGE_INTACCT_DIMENSION_TYPE_FORM]: FormTypes.SageIntacctDimensionForm; [ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM]: FormTypes.SearchAdvancedFiltersForm; [ONYXKEYS.FORMS.TEXT_PICKER_MODAL_FORM]: FormTypes.TextPickerModalForm; + [ONYXKEYS.FORMS.RULES_CUSTOM_NAME_MODAL_FORM]: FormTypes.RulesCustomNameModalForm; + [ONYXKEYS.FORMS.RULES_AUTO_APPROVE_REPORTS_UNDER_MODAL_FORM]: FormTypes.RulesAutoApproveReportsUnderModalForm; + [ONYXKEYS.FORMS.RULES_RANDOM_REPORT_AUDIT_MODAL_FORM]: FormTypes.RulesRandomReportAuditModalForm; + [ONYXKEYS.FORMS.RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM]: FormTypes.RulesAutoPayReportsUnderModalForm; [ONYXKEYS.FORMS.RULES_REQUIRED_RECEIPT_AMOUNT_FORM]: FormTypes.RulesRequiredReceiptAmountForm; [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AMOUNT_FORM]: FormTypes.RulesMaxExpenseAmountForm; [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm; @@ -890,10 +906,11 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_AREA]: number; [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; - [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string; + [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: OnboardingPurposeType; [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; + [ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string; [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 9e9bca2a4037..a28c2ef4fc57 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -57,8 +57,13 @@ const ROUTES = { SEARCH_ADVANCED_FILTERS_IS: 'search/filters/is', SEARCH_REPORT: { - route: 'search/view/:reportID', - getRoute: (reportID: string) => `search/view/${reportID}` as const, + route: 'search/view/:reportID/:reportActionID?', + getRoute: (reportID: string, reportActionID?: string) => { + if (reportActionID) { + return `search/view/${reportID}/${reportActionID}` as const; + } + return `search/view/${reportID}` as const; + }, }, TRANSACTION_HOLD_REASON_RHP: 'search/hold', @@ -705,7 +710,19 @@ const ROUTES = { }, POLICY_ACCOUNTING: { route: 'settings/workspaces/:policyID/accounting', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting` as const, + getRoute: (policyID: string, newConnectionName?: ConnectionName, integrationToDisconnect?: ConnectionName, shouldDisconnectIntegrationBeforeConnecting?: boolean) => { + let queryParams = ''; + if (newConnectionName) { + queryParams += `?newConnectionName=${newConnectionName}`; + if (integrationToDisconnect) { + queryParams += `&integrationToDisconnect=${integrationToDisconnect}`; + } + if (shouldDisconnectIntegrationBeforeConnecting !== undefined) { + queryParams += `&shouldDisconnectIntegrationBeforeConnecting=${shouldDisconnectIntegrationBeforeConnecting}`; + } + } + return `settings/workspaces/${policyID}/accounting${queryParams}` as const; + }, }, WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/advanced', @@ -991,6 +1008,22 @@ const ROUTES = { route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-rate/edit', getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-rate/edit` as const, }, + RULES_CUSTOM_NAME: { + route: 'settings/workspaces/:policyID/rules/name', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/name` as const, + }, + RULES_AUTO_APPROVE_REPORTS_UNDER: { + route: 'settings/workspaces/:policyID/rules/auto-approve', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/auto-approve` as const, + }, + RULES_RANDOM_REPORT_AUDIT: { + route: 'settings/workspaces/:policyID/rules/audit', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/audit` as const, + }, + RULES_AUTO_PAY_REPORTS_UNDER: { + route: 'settings/workspaces/:policyID/rules/auto-pay', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/auto-pay` as const, + }, RULES_RECEIPT_REQUIRED_AMOUNT: { route: 'settings/workspaces/:policyID/rules/receipt-required-amount', getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/receipt-required-amount` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index b66627153ea8..67ba5b84c9ec 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -453,6 +453,10 @@ const SCREENS = { DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit', UPGRADE: 'Workspace_Upgrade', RULES: 'Policy_Rules', + RULES_CUSTOM_NAME: 'Rules_Custom_Name', + RULES_AUTO_APPROVE_REPORTS_UNDER: 'Rules_Auto_Approve_Reports_Under', + RULES_RANDOM_REPORT_AUDIT: 'Rules_Random_Report_Audit', + RULES_AUTO_PAY_REPORTS_UNDER: 'Rules_AutoPay_Reports_Under', RULES_RECEIPT_REQUIRED_AMOUNT: 'Rules_Receipt_Required_Amount', RULES_MAX_EXPENSE_AMOUNT: 'Rules_Max_Expense_Amount', RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age', diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index 8ad01d4437ae..f32d167e7133 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -43,11 +43,13 @@ type AmountFormProps = { /** Custom max amount length. It defaults to CONST.IOU.AMOUNT_MAX_LENGTH */ amountMaxLength?: number; + /** Custom label for the TextInput */ label?: string; + /** Whether the form should use a standard TextInput as a base */ displayAsTextInput?: boolean; } & Pick & - Pick; + Pick; /** * Returns the new selection object based on the updated amount's length @@ -67,7 +69,6 @@ function AmountForm( currency = CONST.CURRENCY.USD, extraDecimals = 0, amountMaxLength, - errorText, onInputChange, onCurrencyButtonPress, displayAsTextInput = false, @@ -296,11 +297,11 @@ function AmountForm( // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> - {!!errorText && ( + {!!rest.errorText && ( )} diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 14b7ac6f2313..c327d7fa6093 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -246,13 +246,14 @@ function AttachmentModal({ } if (typeof sourceURL === 'string') { - fileDownload(sourceURL, file?.name ?? ''); + const fileName = type === CONST.ATTACHMENT_TYPE.SEARCH ? FileUtils.getFileName(`${sourceURL}`) : file?.name; + fileDownload(sourceURL, fileName ?? ''); } // At ios, if the keyboard is open while opening the attachment, then after downloading // the attachment keyboard will show up. So, to fix it we need to dismiss the keyboard. Keyboard.dismiss(); - }, [isAuthTokenRequiredState, sourceState, file]); + }, [isAuthTokenRequiredState, sourceState, file, type]); /** * Execute the onConfirm callback and close the modal. @@ -460,7 +461,7 @@ function AttachmentModal({ let headerTitleNew = headerTitle; let shouldShowDownloadButton = false; let shouldShowThreeDotsButton = false; - if (!isEmptyObject(report)) { + if (!isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH) { headerTitleNew = translate(isReceiptAttachment ? 'common.receipt' : 'common.attachment'); shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isReceiptAttachment && !isOffline && !isLocalSource; shouldShowThreeDotsButton = isReceiptAttachment && isModalOpen && threeDotsMenuItems.length !== 0; diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 2ccdd47c3205..1cd1bfb36d83 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -12,7 +12,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; +import type {PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; import CaretWrapper from './CaretWrapper'; import DisplayNames from './DisplayNames'; import MultipleAvatars from './MultipleAvatars'; @@ -69,7 +69,7 @@ function AvatarWithDisplayName({ ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report) || ReportUtils.isInvoiceReport(report); const icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails) as PersonalDetails[], false); + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false); const shouldShowSubscriptAvatar = ReportUtils.shouldReportShowSubscript(report); const avatarBorderColor = isAnonymous ? theme.highlightBG : theme.componentBG; diff --git a/src/components/BulletList.tsx b/src/components/BulletList.tsx new file mode 100644 index 000000000000..8aee1aa5076f --- /dev/null +++ b/src/components/BulletList.tsx @@ -0,0 +1,52 @@ +import type {ReactNode} from 'react'; +import React from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Text from './Text'; + +type BulletListItem = string; + +type BulletListProps = { + /** List of items for the list. Each item will be rendered as a sepearte point. */ + items: BulletListItem[]; + + /** Header section of the list */ + header: string | ReactNode; +}; + +function BulletList({items, header}: BulletListProps) { + const styles = useThemeStyles(); + + const baseTextStyles = [styles.mutedNormalTextLabel]; + + const renderBulletListHeader = () => { + if (typeof header === 'string') { + return {header}; + } + return header; + }; + + const renderBulletPoint = (item: string) => { + return ( + + β€’ + {item} + + ); + }; + + return ( + + {renderBulletListHeader()} + {items.map((item) => renderBulletPoint(item))} + + ); +} + +BulletList.displayName = 'BulletList'; + +export type {BulletListProps}; +export default BulletList; diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index e9558297e577..cc99ca636488 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -19,6 +19,9 @@ type ConfirmModalProps = { /** A callback to call when the form has been closed */ onCancel?: () => void; + /** A callback to call when backdrop is pressed */ + onBackdropPress?: () => void; + /** Modal visibility */ isVisible: boolean; @@ -108,6 +111,7 @@ function ConfirmModal({ success = true, danger = false, onCancel = () => {}, + onBackdropPress = () => {}, shouldDisableConfirmButtonWhenOffline = false, shouldShowCancelButton = true, shouldSetModalVisibility = true, @@ -140,6 +144,7 @@ function ConfirmModal({ void; + delegatorEmail: string; +}; + +export default function DelegateNoAccessModal({isNoDelegateAccessMenuVisible = false, onClose, delegatorEmail = ''}: DelegateNoAccessModalProps) { + const {translate} = useLocalize(); + const noDelegateAccessPromptStart = translate('delegate.notAllowedMessageStart', {accountOwnerEmail: delegatorEmail}); + const noDelegateAccessHyperLinked = translate('delegate.notAllowedMessageHyperLinked'); + const noDelegateAccessPromptEnd = translate('delegate.notAllowedMessageEnd'); + + const delegateNoAccessPrompt = ( + + {noDelegateAccessPromptStart} + {noDelegateAccessHyperLinked} + {noDelegateAccessPromptEnd} + + ); + + return ( + + ); +} diff --git a/src/components/EReceipt.tsx b/src/components/EReceipt.tsx index bfb59dc748ab..026713027f96 100644 --- a/src/components/EReceipt.tsx +++ b/src/components/EReceipt.tsx @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -44,7 +45,7 @@ function EReceipt({transaction, transactionID}: EReceiptProps) { const formattedAmount = CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency); const currency = CurrencyUtils.getCurrencySymbol(transactionCurrency ?? ''); const amount = currency ? formattedAmount.replace(currency, '') : formattedAmount; - const cardDescription = transactionCardID ? CardUtils.getCardDescription(transactionCardID) : ''; + const cardDescription = TransactionUtils.getCardName(transaction) ?? (transactionCardID ? CardUtils.getCardDescription(transactionCardID) : ''); const secondaryTextColorStyle = secondaryColor ? StyleUtils.getColorStyle(secondaryColor) : undefined; diff --git a/src/components/ExplanationModal.tsx b/src/components/ExplanationModal.tsx index c6294f600993..73290c43d39a 100644 --- a/src/components/ExplanationModal.tsx +++ b/src/components/ExplanationModal.tsx @@ -3,8 +3,8 @@ import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import FeatureTrainingModal from './FeatureTrainingModal'; function ExplanationModal() { @@ -18,7 +18,7 @@ function ExplanationModal() { onNotCompleted: () => { setTimeout(() => { Navigation.isNavigationReady().then(() => { - Navigation.navigate(ROUTES.ONBOARDING_ROOT.route); + OnboardingFlow.startOnboardingFlow(); }); }, variables.welcomeVideoDelay); }, diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 5f56bbeceea6..88ccc31c0979 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -11,6 +11,7 @@ import type CountrySelector from '@components/CountrySelector'; import type CurrencySelector from '@components/CurrencySelector'; import type DatePicker from '@components/DatePicker'; import type EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown'; +import type PercentageForm from '@components/PercentageForm'; import type Picker from '@components/Picker'; import type RadioButtons from '@components/RadioButtons'; import type RoomNameInput from '@components/RoomNameInput'; @@ -42,6 +43,7 @@ type ValidInputs = | typeof CountrySelector | typeof CurrencySelector | typeof AmountForm + | typeof PercentageForm | typeof BusinessTypePicker | typeof DimensionTypeSelector | typeof StateSelector diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 771d2631379e..99699b9ef3c6 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -90,10 +90,8 @@ function ImageRenderer({tnode}: ImageRendererProps) { return; } - if (reportID) { - const route = ROUTES.ATTACHMENTS?.getRoute(reportID, type, source, accountID); - Navigation.navigate(route); - } + const route = ROUTES.ATTACHMENTS?.getRoute(reportID ?? '-1', type, source, accountID); + Navigation.navigate(route); }} onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx index e0df7e7081c5..ce822af14cb8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type {CustomRendererProps, TBlock} from 'react-native-render-html'; +import {AttachmentContext} from '@components/AttachmentContext'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import VideoPlayerPreview from '@components/VideoPlayerPreview'; import useCurrentReportID from '@hooks/useCurrentReportID'; @@ -28,19 +29,26 @@ function VideoRenderer({tnode, key}: VideoRendererProps) { return ( {({report}) => ( - { - const route = ROUTES.ATTACHMENTS.getRoute(report?.reportID ?? '-1', CONST.ATTACHMENT_TYPE.REPORT, sourceURL); - Navigation.navigate(route); - }} - /> + + {({accountID, type}) => ( + { + if (!sourceURL || !type) { + return; + } + const route = ROUTES.ATTACHMENTS.getRoute(report?.reportID ?? '-1', type, sourceURL, accountID); + Navigation.navigate(route); + }} + /> + )} + )} ); diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 0616794a8e3a..bc12bc6c135b 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -56,6 +56,7 @@ import CheckmarkCircle from '@assets/images/simple-illustrations/simple-illustra import CoffeeMug from '@assets/images/simple-illustrations/simple-illustration__coffeemug.svg'; import Coins from '@assets/images/simple-illustrations/simple-illustration__coins.svg'; import CommentBubbles from '@assets/images/simple-illustrations/simple-illustration__commentbubbles.svg'; +import CommentBubblesBlue from '@assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg'; import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustration__concierge-bubble.svg'; import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg'; import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; @@ -182,6 +183,7 @@ export { SmartScan, Hourglass, CommentBubbles, + CommentBubblesBlue, TrashCan, TeleScope, Profile, diff --git a/src/components/LocationPermissionModal/index.android.tsx b/src/components/LocationPermissionModal/index.android.tsx index 811537e00e67..30896cf37084 100644 --- a/src/components/LocationPermissionModal/index.android.tsx +++ b/src/components/LocationPermissionModal/index.android.tsx @@ -63,11 +63,17 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe setHasError(false); }; + const closeModal = () => { + setShowModal(false); + resetPermissionFlow(); + }; + return ( { + setShowModal(false); + resetPermissionFlow(); + }; return ( { if (!type || !chatReport) { @@ -147,7 +151,9 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea } setPaymentType(type); setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); - if (isAnyTransactionOnHold) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (isAnyTransactionOnHold) { setIsHoldMenuVisible(true); } else if (ReportUtils.isInvoiceReport(moneyRequestReport)) { IOU.payInvoice(type, chatReport, moneyRequestReport, payAsBusiness); @@ -158,7 +164,9 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const confirmApproval = () => { setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); - if (isAnyTransactionOnHold) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (isAnyTransactionOnHold) { setIsHoldMenuVisible(true); } else { IOU.approveMoneyRequest(moneyRequestReport, true); @@ -400,6 +408,12 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea transactionCount={transactionIDs.length} /> )} + setIsNoDelegateAccessMenuVisible(false)} + delegatorEmail={delegatorEmail ?? ''} + /> + void; + + /** Custom label for the TextInput */ + label?: string; +}; + +/** + * Returns the new selection object based on the updated amount's length + */ +const getNewSelection = (oldSelection: {start: number; end: number}, prevLength: number, newLength: number) => { + const cursorPosition = oldSelection.end + (newLength - prevLength); + return {start: cursorPosition, end: cursorPosition}; +}; + +function PercentageForm({value: amount, errorText, onInputChange, label, ...rest}: PercentageFormProps, forwardedRef: ForwardedRef) { + const {toLocaleDigit, numberFormat} = useLocalize(); + + const textInput = useRef(null); + + const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); + + const [selection, setSelection] = useState({ + start: currentAmount.length, + end: currentAmount.length, + }); + + const forwardDeletePressedRef = useRef(false); + + /** + * Sets the selection and the amount accordingly to the value passed to the input + * @param newAmount - Changed amount from user input + */ + const setNewAmount = useCallback( + (newAmount: string) => { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); + // Use a shallow copy of selection to trigger setSelection + // More info: https://github.com/Expensify/App/issues/16385 + if (!MoneyRequestUtils.validatePercentage(newAmountWithoutSpaces)) { + setSelection((prevSelection) => ({...prevSelection})); + return; + } + + const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); + const isForwardDelete = currentAmount.length > strippedAmount.length && forwardDeletePressedRef.current; + setSelection(getNewSelection(selection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length)); + onInputChange?.(strippedAmount); + }, + [currentAmount, onInputChange, selection], + ); + + const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); + + return ( + { + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef && 'current' in forwardedRef) { + // eslint-disable-next-line no-param-reassign + forwardedRef.current = ref; + } + textInput.current = ref; + }} + selection={selection} + onSelectionChange={(e: NativeSyntheticEvent) => { + setSelection(e.nativeEvent.selection); + }} + suffixCharacter="%" + keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + /> + ); +} + +PercentageForm.displayName = 'PercentageForm'; + +export default forwardRef(PercentageForm); +export type {PercentageFormProps}; diff --git a/src/components/PromotedActionsBar.tsx b/src/components/PromotedActionsBar.tsx index b0309d702f9a..bf7b1aeff003 100644 --- a/src/components/PromotedActionsBar.tsx +++ b/src/components/PromotedActionsBar.tsx @@ -29,7 +29,13 @@ type BasePromotedActions = typeof CONST.PROMOTED_ACTIONS.PIN | typeof CONST.PROM type PromotedActionsType = Record PromotedAction> & { message: (params: {reportID?: string; accountID?: number; login?: string}) => PromotedAction; } & { - hold: (params: {isTextHold: boolean; reportAction: ReportAction | undefined; reportID?: string}) => PromotedAction; + hold: (params: { + isTextHold: boolean; + reportAction: ReportAction | undefined; + reportID?: string; + isDelegateAccessRestricted: boolean; + setIsNoDelegateAccessMenuVisible: (isVisible: boolean) => void; + }) => PromotedAction; }; const PromotedActions = { @@ -70,11 +76,16 @@ const PromotedActions = { } }, }), - hold: ({isTextHold, reportAction, reportID}) => ({ + hold: ({isTextHold, reportAction, reportID, isDelegateAccessRestricted, setIsNoDelegateAccessMenuVisible}) => ({ key: CONST.PROMOTED_ACTIONS.HOLD, icon: Expensicons.Stopwatch, text: Localize.translateLocal(`iou.${isTextHold ? 'hold' : 'unhold'}`), onSelected: () => { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); // Show the menu + return; + } + if (!isTextHold) { Navigation.goBack(); } diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 6ab1c0937278..f422269bfc69 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -136,7 +136,7 @@ function MoneyRequestPreviewContent({ const duplicates = useMemo(() => TransactionUtils.removeSettledAndApprovedTransactions(allDuplicates), [allDuplicates]); // When there are no settled transactions in duplicates, show the "Keep this one" button - const shouldShowKeepButton = allDuplicates.length === duplicates.length; + const shouldShowKeepButton = !!(allDuplicates.length && duplicates.length && allDuplicates.length === duplicates.length); const hasDuplicates = duplicates.length > 0; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index d76c9123bb92..2b93880c98c1 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -5,6 +5,7 @@ import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useOnyx, withOnyx} from 'react-native-onyx'; import Button from '@components/Button'; +import DelegateNoAccessModal from '@components/DelegateNoAccessModal'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -14,6 +15,7 @@ import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; import AnimatedSettlementButton from '@components/SettlementButton/AnimatedSettlementButton'; import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; +import useDelegateUserDetails from '@hooks/useDelegateUserDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; @@ -192,13 +194,18 @@ function ReportPreview({ [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled], ); + const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); + const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); + const confirmPayment = (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type) { return; } setPaymentType(type); setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); - if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else if (chatReport && iouReport) { HapticFeedback.longPress(); @@ -213,7 +220,9 @@ function ReportPreview({ const confirmApproval = () => { setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); - if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else { IOU.approveMoneyRequest(iouReport, true); @@ -518,6 +527,12 @@ function ReportPreview({ + setIsNoDelegateAccessMenuVisible(false)} + delegatorEmail={delegatorEmail ?? ''} + /> + {isHoldMenuVisible && iouReport && requestType !== undefined && ( void; setOfflineModalOpen?: () => void; setDownloadErrorModalOpen?: () => void; - data?: TransactionListItemType[] | ReportListItemType[]; + data?: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[]; }; type SearchHeaderOptionValue = DeepValueOf | undefined; @@ -111,6 +111,8 @@ function getHeaderContent(type: SearchDataTypes): HeaderContent { return {icon: Illustrations.EnvelopeReceipt, titleText: 'workspace.common.invoices'}; case CONST.SEARCH.DATA_TYPES.TRIP: return {icon: Illustrations.Luggage, titleText: 'travel.trips'}; + case CONST.SEARCH.DATA_TYPES.CHAT: + return {icon: Illustrations.CommentBubblesBlue, titleText: 'common.chats'}; case CONST.SEARCH.DATA_TYPES.EXPENSE: default: return {icon: Illustrations.MoneyReceipts, titleText: 'common.expenses'}; @@ -135,6 +137,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa .filter( (item) => !SearchUtils.isTransactionListItemType(item) && + !SearchUtils.isReportActionListItemType(item) && item.reportID && item.transactions.every((transaction: {keyForList: string | number}) => selectedTransactions[transaction.keyForList]?.isSelected), ) diff --git a/src/components/Search/SearchStatusBar.tsx b/src/components/Search/SearchStatusBar.tsx index 7c1ffeff1818..b8b2b3fd05d0 100644 --- a/src/components/Search/SearchStatusBar.tsx +++ b/src/components/Search/SearchStatusBar.tsx @@ -13,11 +13,12 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryString, SearchStatus, TripSearchStatus} from './types'; +import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryString, SearchStatus, TripSearchStatus} from './types'; type SearchStatusBarProps = { type: SearchDataTypes; status: SearchStatus; + resetOffset: () => void; }; const expenseOptions: Array<{key: ExpenseSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ @@ -82,28 +83,49 @@ const tripOptions: Array<{key: TripSearchStatus; icon: IconAsset; text: Translat query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.ALL), }, { - key: CONST.SEARCH.STATUS.TRIP.DRAFTS, - icon: Expensicons.Pencil, - text: 'common.drafts', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.DRAFTS), + key: CONST.SEARCH.STATUS.TRIP.CURRENT, + icon: Expensicons.Calendar, + text: 'search.filters.current', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.CURRENT), }, { - key: CONST.SEARCH.STATUS.TRIP.OUTSTANDING, - icon: Expensicons.Hourglass, - text: 'common.outstanding', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.OUTSTANDING), + key: CONST.SEARCH.STATUS.TRIP.PAST, + icon: Expensicons.History, + text: 'search.filters.past', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.PAST), }, +]; + +const chatOptions: Array<{key: ChatSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ { - key: CONST.SEARCH.STATUS.TRIP.APPROVED, - icon: Expensicons.ThumbsUp, - text: 'iou.approved', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.APPROVED), + key: CONST.SEARCH.STATUS.CHAT.ALL, + icon: Expensicons.All, + text: 'common.all', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.ALL), }, { - key: CONST.SEARCH.STATUS.TRIP.PAID, - icon: Expensicons.MoneyBag, - text: 'iou.settledExpensify', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.PAID), + key: CONST.SEARCH.STATUS.CHAT.UNREAD, + icon: Expensicons.ChatBubbleUnread, + text: 'common.unread', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.UNREAD), + }, + { + key: CONST.SEARCH.STATUS.CHAT.SENT, + icon: Expensicons.Send, + text: 'common.sent', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.SENT), + }, + { + key: CONST.SEARCH.STATUS.CHAT.ATTACHMENTS, + icon: Expensicons.Document, + text: 'common.attachments', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.ATTACHMENTS), + }, + { + key: CONST.SEARCH.STATUS.CHAT.LINKS, + icon: Expensicons.Paperclip, + text: 'common.links', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.LINKS), }, ]; @@ -113,13 +135,15 @@ function getOptions(type: SearchDataTypes) { return invoiceOptions; case CONST.SEARCH.DATA_TYPES.TRIP: return tripOptions; + case CONST.SEARCH.DATA_TYPES.CHAT: + return chatOptions; case CONST.SEARCH.DATA_TYPES.EXPENSE: default: return expenseOptions; } } -function SearchStatusBar({type, status}: SearchStatusBarProps) { +function SearchStatusBar({type, status, resetOffset}: SearchStatusBarProps) { const {singleExecution} = useSingleExecution(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -134,7 +158,10 @@ function SearchStatusBar({type, status}: SearchStatusBarProps) { showsHorizontalScrollIndicator={false} > {options.map((item, index) => { - const onPress = singleExecution(() => Navigation.setParams({q: item.query})); + const onPress = singleExecution(() => { + resetOffset(); + Navigation.setParams({q: item.query}); + }); const isActive = status === item.key; const isFirstItem = index === 0; const isLastItem = index === options.length - 1; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 8c4530b08b64..8296b494b6fc 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -3,10 +3,11 @@ import type {StackNavigationProp} from '@react-navigation/stack'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; import SearchTableHeader from '@components/SelectionList/SearchTableHeader'; -import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import SearchStatusSkeleton from '@components/Skeletons/SearchStatusSkeleton'; @@ -54,7 +55,10 @@ function mapToTransactionItemWithSelectionInfo(item: TransactionListItemType, se return {...item, isSelected: selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple}; } -function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean) { +function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType | ReportActionListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean) { + if (SearchUtils.isReportActionListItemType(item)) { + return item; + } return SearchUtils.isTransactionListItemType(item) ? mapToTransactionItemWithSelectionInfo(item, selectedTransactions, canSelectMultiple) : { @@ -142,8 +146,8 @@ function Search({queryJSON}: SearchProps) { }; const getItemHeight = useCallback( - (item: TransactionListItemType | ReportListItemType) => { - if (SearchUtils.isTransactionListItemType(item)) { + (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { + if (SearchUtils.isTransactionListItemType(item) || SearchUtils.isReportActionListItemType(item)) { return isLargeScreenWidth ? variables.optionRowHeight + listItemPadding : transactionItemMobileHeight + listItemPadding; } @@ -161,6 +165,8 @@ function Search({queryJSON}: SearchProps) { [isLargeScreenWidth], ); + const resetOffset = () => setOffset(0); + const getItemHeightMemoized = memoize(getItemHeight, { transformKey: ([item]) => { // List items are displayed differently on "L"arge and "N"arrow screens so the height will differ @@ -205,6 +211,7 @@ function Search({queryJSON}: SearchProps) { ) : ( @@ -216,12 +223,12 @@ function Search({queryJSON}: SearchProps) { if (searchResults === undefined) { Log.alert('[Search] Undefined search type'); - return null; + return {null}; } - const ListItem = SearchUtils.getListItem(status); - const data = SearchUtils.getSections(status, searchResults.data, searchResults.search); - const sortedData = SearchUtils.getSortedSections(status, data, sortBy, sortOrder); + const ListItem = SearchUtils.getListItem(type, status); + const data = SearchUtils.getSections(type, status, searchResults.data, searchResults.search); + const sortedData = SearchUtils.getSortedSections(type, status, data, sortBy, sortOrder); const sortedSelectedData = sortedData.map((item) => mapToItemWithSelectionInfo(item, selectedTransactions, canSelectMultiple)); const shouldShowEmptyState = !isDataLoaded || data.length === 0; @@ -236,13 +243,17 @@ function Search({queryJSON}: SearchProps) { ); } - const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => { + const toggleTransaction = (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { + if (SearchUtils.isReportActionListItemType(item)) { + return; + } if (SearchUtils.isTransactionListItemType(item)) { if (!item.keyForList) { return; @@ -269,7 +280,7 @@ function Search({queryJSON}: SearchProps) { }); }; - const openReport = (item: TransactionListItemType | ReportListItemType) => { + const openReport = (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { let reportID = SearchUtils.isTransactionListItemType(item) && !item.isFromOneTransactionReport ? item.transactionThreadReportID : item.reportID; if (!reportID) { @@ -282,6 +293,12 @@ function Search({queryJSON}: SearchProps) { SearchActions.createTransactionThread(hash, item.transactionID, reportID, item.moneyRequestReportActionID); } + if (SearchUtils.isReportActionListItemType(item)) { + const reportActionID = item.reportActionID; + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(reportID, reportActionID)); + return; + } + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(reportID)); }; @@ -332,10 +349,11 @@ function Search({queryJSON}: SearchProps) { - + sections={[{data: sortedSelectedData, isDisabled: false}]} - turnOnSelectionModeOnLongPress + turnOnSelectionModeOnLongPress={type !== CONST.SEARCH.DATA_TYPES.CHAT} onTurnOnSelectionMode={(item) => item && toggleTransaction(item)} onCheckboxPress={toggleTransaction} onSelectAll={toggleAllTransactions} @@ -352,7 +370,7 @@ function Search({queryJSON}: SearchProps) { /> ) } - canSelectMultiple={canSelectMultiple} + canSelectMultiple={type !== CONST.SEARCH.DATA_TYPES.CHAT && canSelectMultiple} customListHeaderHeight={searchHeaderHeight} // To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling, // we have configured a larger windowSize and a longer delay between batch renders. diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 9f2aca1ff957..b22c8e58e122 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -28,7 +28,8 @@ type SearchColumnType = ValueOf; type ExpenseSearchStatus = ValueOf; type InvoiceSearchStatus = ValueOf; type TripSearchStatus = ValueOf; -type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus; +type ChatSearchStatus = ValueOf; +type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus | ChatSearchStatus; type SearchContext = { currentSearchHash: number; @@ -41,7 +42,7 @@ type SearchContext = { type ASTNode = { operator: ValueOf; left: ValueOf | ASTNode; - right: string | ASTNode; + right: string | ASTNode | string[]; }; type QueryFilter = { @@ -88,4 +89,5 @@ export type { ExpenseSearchStatus, InvoiceSearchStatus, TripSearchStatus, + ChatSearchStatus, }; diff --git a/src/components/SelectionList/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx new file mode 100644 index 000000000000..1a27e0ecbfcf --- /dev/null +++ b/src/components/SelectionList/ChatListItem.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import {View} from 'react-native'; +import {AttachmentContext} from '@components/AttachmentContext'; +import MultipleAvatars from '@components/MultipleAvatars'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ReportActionItemDate from '@pages/home/report/ReportActionItemDate'; +import ReportActionItemFragment from '@pages/home/report/ReportActionItemFragment'; +import CONST from '@src/CONST'; +import BaseListItem from './BaseListItem'; +import type {ChatListItemProps, ListItem, ReportActionListItemType} from './types'; + +function ChatListItem({ + item, + isFocused, + showTooltip, + isDisabled, + canSelectMultiple, + onSelectRow, + onDismissError, + onFocus, + onLongPressRow, + shouldSyncFocus, +}: ChatListItemProps) { + const reportActionItem = item as unknown as ReportActionListItemType; + const from = reportActionItem.from; + const icons = [ + { + type: CONST.ICON_TYPE_AVATAR, + source: from.avatar, + name: reportActionItem.formattedFrom, + id: from.accountID, + }, + ]; + const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + + const attachmentContextValue = {type: CONST.ATTACHMENT_TYPE.SEARCH}; + + const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; + const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; + + return ( + + {(hovered) => ( + + + + + + + + + + + {reportActionItem.message.map((fragment, index) => ( + + ))} + + + + )} + + ); +} + +ChatListItem.displayName = 'ChatListItem'; + +export default ChatListItem; diff --git a/src/components/SelectionList/SearchTableHeader.tsx b/src/components/SelectionList/SearchTableHeader.tsx index cb1914824a20..f54532a7f318 100644 --- a/src/components/SelectionList/SearchTableHeader.tsx +++ b/src/components/SelectionList/SearchTableHeader.tsx @@ -85,6 +85,13 @@ const expenseHeaders: SearchColumnConfig[] = [ }, ]; +const SearchColumns = { + [CONST.SEARCH.DATA_TYPES.EXPENSE]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.INVOICE]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.TRIP]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.CHAT]: null, +}; + type SearchTableHeaderProps = { data: OnyxTypes.SearchResults['data']; metadata: OnyxTypes.SearchResults['search']; @@ -102,6 +109,10 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, onSortPress, shou const {translate} = useLocalize(); const displayNarrowVersion = isMediumScreenWidth || isSmallScreenWidth; + if (SearchColumns[metadata.type] === null) { + return; + } + if (displayNarrowVersion) { return; } @@ -109,7 +120,7 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, onSortPress, shou return ( - {expenseHeaders.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { + {SearchColumns[metadata.type]?.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { if (!shouldShow(data, metadata)) { return null; } diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index c484a59fee78..ea0fb35932dd 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -5,10 +5,11 @@ import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; import type CursorStyles from '@styles/utils/cursor/types'; import type CONST from '@src/CONST'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {SearchPersonalDetails, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; +import type {SearchPersonalDetails, SearchReport, SearchReportAction, SearchTransaction} from '@src/types/onyx/SearchResults'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type IconAsset from '@src/types/utils/IconAsset'; +import type ChatListItem from './ChatListItem'; import type InviteMemberListItem from './InviteMemberListItem'; import type RadioListItem from './RadioListItem'; import type ReportListItem from './Search/ReportListItem'; @@ -206,6 +207,21 @@ type TransactionListItemType = ListItem & keyForList: string; }; +type ReportActionListItemType = ListItem & + SearchReportAction & { + /** The personal details of the user posting comment */ + from: SearchPersonalDetails; + + /** final and formatted "from" value used for displaying and sorting */ + formattedFrom: string; + + /** final "date" value used for sorting */ + date: string; + + /** Key used internally by React */ + keyForList: string; + }; + type ReportListItemType = ListItem & SearchReport & { /** The personal details of the user requesting money */ @@ -277,7 +293,16 @@ type TransactionListItemProps = ListItemProps; type ReportListItemProps = ListItemProps; -type ValidListItem = typeof RadioListItem | typeof UserListItem | typeof TableListItem | typeof InviteMemberListItem | typeof TransactionListItem | typeof ReportListItem; +type ChatListItemProps = ListItemProps; + +type ValidListItem = + | typeof RadioListItem + | typeof UserListItem + | typeof TableListItem + | typeof InviteMemberListItem + | typeof TransactionListItem + | typeof ReportListItem + | typeof ChatListItem; type Section = { /** Title of the section */ @@ -556,4 +581,6 @@ export type { TransactionListItemType, UserListItemProps, ValidListItem, + ReportActionListItemType, + ChatListItemProps, }; diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx index ca34e579a431..2d218bc815fe 100644 --- a/src/components/SelectionListWithModal/index.tsx +++ b/src/components/SelectionListWithModal/index.tsx @@ -23,13 +23,15 @@ function SelectionListWithModal( const [isModalVisible, setIsModalVisible] = useState(false); const [longPressedItem, setLongPressedItem] = useState(null); const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout here because there is a race condition that causes shouldUseNarrowLayout to change indefinitely in this component + // See https://github.com/Expensify/App/issues/48675 for more details + const {isSmallScreenWidth} = useResponsiveLayout(); const {selectionMode} = useMobileSelectionMode(true); useEffect(() => { // We can access 0 index safely as we are not displaying multiple sections in table view const selectedItems = sections[0].data.filter((item) => item.isSelected); - if (!shouldUseNarrowLayout) { + if (!isSmallScreenWidth) { if (selectedItems.length === 0) { turnOffMobileSelectionMode(); } @@ -38,11 +40,11 @@ function SelectionListWithModal( if (selectedItems.length > 0 && !selectionMode?.isEnabled) { turnOnMobileSelectionMode(); } - }, [sections, selectionMode, shouldUseNarrowLayout]); + }, [sections, selectionMode, isSmallScreenWidth]); const handleLongPressRow = (item: TItem) => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (!turnOnSelectionModeOnLongPress || !shouldUseNarrowLayout || item?.isDisabled || item?.isDisabledCheckbox) { + if (!turnOnSelectionModeOnLongPress || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox) { return; } setLongPressedItem(item); diff --git a/src/components/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/hooks/useDelegateUserDetails.ts b/src/hooks/useDelegateUserDetails.ts new file mode 100644 index 000000000000..6e90f2906277 --- /dev/null +++ b/src/hooks/useDelegateUserDetails.ts @@ -0,0 +1,18 @@ +import {useOnyx} from 'react-native-onyx'; +import AccountUtils from '@libs/AccountUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; + +function useDelegateUserDetails() { + const currentUserDetails = useCurrentUserPersonalDetails(); + const [currentUserAccountDetails] = useOnyx(ONYXKEYS.ACCOUNT); + const isDelegateAccessRestricted = AccountUtils.isDelegateOnlySubmitter(currentUserAccountDetails); + const delegatorEmail = currentUserDetails?.login; + + return { + isDelegateAccessRestricted, + delegatorEmail, + }; +} + +export default useDelegateUserDetails; diff --git a/src/languages/en.ts b/src/languages/en.ts index c2902dc3d8a5..e61eba949a8b 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5,6 +5,7 @@ import type {Country} from '@src/CONST'; import type {DelegateRole} from '@src/types/onyx/Account'; import type {ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName} from '@src/types/onyx/Policy'; import type { + AccountOwnerParams, AddressLineParams, AdminCanceledRequestParams, AlreadySignedInParams, @@ -147,6 +148,7 @@ export default { buttonConfirm: 'Got it', name: 'Name', attachment: 'Attachment', + attachments: 'Attachments', center: 'Center', from: 'From', to: 'To', @@ -178,6 +180,7 @@ export default { profile: 'Profile', referral: 'Referral', payments: 'Payments', + approvals: 'Approvals', wallet: 'Wallet', preferences: 'Preferences', view: 'View', @@ -253,6 +256,7 @@ export default { conjunctionAt: 'at', conjunctionTo: 'to', genericErrorMessage: 'Oops... something went wrong and your request could not be completed. Please try again later.', + percentage: 'Percentage', error: { invalidAmount: 'Invalid amount.', acceptTerms: 'You must accept the Terms of Service to continue.', @@ -388,6 +392,10 @@ export default { importSpreadsheet: 'Import spreadsheet', offlinePrompt: "You can't take this action right now.", outstanding: 'Outstanding', + chats: 'Chats', + unread: 'Unread', + sent: 'Sent', + links: 'Links', days: 'days', }, location: { @@ -861,10 +869,9 @@ export default { genericHoldExpenseFailureMessage: 'Unexpected error holding this expense. Please try again later.', genericUnholdExpenseFailureMessage: 'Unexpected error taking this expense off hold. Please try again later.', receiptDeleteFailureError: 'Unexpected error deleting this receipt. Please try again later.', - // eslint-disable-next-line rulesdir/use-periods-for-error-messages receiptFailureMessage: "The receipt didn't upload.", - saveFileMessage: 'Download the file ', // eslint-disable-next-line rulesdir/use-periods-for-error-messages + saveFileMessage: 'Download the file ', loseFileMessage: 'or dismiss this error and lose it.', genericDeleteFailureMessage: 'Unexpected error deleting this expense. Please try again later.', genericEditFailureMessage: 'Unexpected error editing this expense. Please try again later.', @@ -1775,33 +1782,33 @@ export default { hasBeenThrottledError: 'An error occurred while adding your bank account. Please wait a few minutes and try again.', hasCurrencyError: 'Oops! It appears that your workspace currency is set to a different currency than USD. To proceed, please set it to USD and try again.', error: { - youNeedToSelectAnOption: 'Please select an option to proceed', - noBankAccountAvailable: "Sorry, there's no bank account available", - noBankAccountSelected: 'Please choose an account', - taxID: 'Please enter a valid tax ID number', - website: 'Please enter a valid website using lower-case letters', - zipCode: `Please enter a valid ZIP code using the format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`, - phoneNumber: 'Please enter a valid phone number', - companyName: 'Please enter a valid business name', - addressCity: 'Please enter a valid city', - addressStreet: 'Please enter a valid street address', - addressState: 'Please select a valid state', - incorporationDateFuture: "Incorporation date can't be in the future", - incorporationState: 'Please select a valid state', - industryCode: 'Please enter a valid industry classification code with six digits', - restrictedBusiness: "Please confirm the business isn't on the list of restricted businesses", - routingNumber: 'Please enter a valid routing number', - accountNumber: 'Please enter a valid account number', - routingAndAccountNumberCannotBeSame: "Routing and account numbers can't match", - companyType: 'Please select a valid company type', + youNeedToSelectAnOption: 'Please select an option to proceed.', + noBankAccountAvailable: "Sorry, there's no bank account available.", + noBankAccountSelected: 'Please choose an account.', + taxID: 'Please enter a valid tax ID number.', + website: 'Please enter a valid website using lower-case letters.', + zipCode: `Please enter a valid ZIP code using the format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}.`, + phoneNumber: 'Please enter a valid phone number.', + companyName: 'Please enter a valid business name.', + addressCity: 'Please enter a valid city.', + addressStreet: 'Please enter a valid street address.', + addressState: 'Please select a valid state.', + incorporationDateFuture: "Incorporation date can't be in the future.", + incorporationState: 'Please select a valid state.', + industryCode: 'Please enter a valid industry classification code with six digits.', + restrictedBusiness: "Please confirm the business isn't on the list of restricted businesses.", + routingNumber: 'Please enter a valid routing number.', + accountNumber: 'Please enter a valid account number.', + routingAndAccountNumberCannotBeSame: "Routing and account numbers can't match.", + companyType: 'Please select a valid company type.', tooManyAttempts: 'Due to a high number of login attempts, this option has been disabled for 24 hours. Please try again later or enter details manually instead.', - address: 'Please enter a valid address', - dob: 'Please select a valid date of birth', - age: 'Must be over 18 years old', - ssnLast4: 'Please enter valid last 4 digits of SSN', - firstName: 'Please enter a valid first name', - lastName: 'Please enter a valid last name', - noDefaultDepositAccountOrDebitCardAvailable: 'Please add a default deposit account or debit card', + address: 'Please enter a valid address.', + dob: 'Please select a valid date of birth.', + age: 'Must be over 18 years old.', + ssnLast4: 'Please enter valid last 4 digits of SSN.', + firstName: 'Please enter a valid first name.', + lastName: 'Please enter a valid last name.', + noDefaultDepositAccountOrDebitCardAvailable: 'Please add a default deposit account or debit card.', validationAmounts: 'The validation amounts you entered are incorrect. Please double check your bank statement and try again.', }, }, @@ -3731,8 +3738,37 @@ export default { eReceiptsHintLink: 'for most USD credit transactions', }, expenseReportRules: { + examples: 'Examples:', title: 'Expense reports', subtitle: 'Automate expense report compliance, approvals, and payment.', + customReportNamesTitle: 'Custom report names', + customReportNamesSubtitle: 'Create custom names using our extensive formulas.', + customNameTitle: 'Custom name', + customNameDescription: 'Choose a custom name for expense reports using our ', + customNameDescriptionLink: 'extensive formulas', + customNameInputLabel: 'Name', + customNameEmailPhoneExample: 'Member’s email or phone: {report:submit:from}', + customNameStartDateExample: 'Report start date: {report:startdate}', + customNameWorkspaceNameExample: 'Workspace name: {report:policyname}', + customNameReportIDExample: 'Report ID: {report:id}', + customNameTotalExample: 'Total: {report:total}.', + preventMembersFromChangingCustomNamesTitle: 'Prevent members from changing custom report names', + preventSelfApprovalsTitle: 'Prevent self-approvals', + preventSelfApprovalsSubtitle: 'Prevent workspace members from approving their own expense reports.', + autoApproveCompliantReportsTitle: 'Auto-approve compliant reports', + autoApproveCompliantReportsSubtitle: 'Configure which expense reports are eligible for auto-approval.', + autoApproveReportsUnderTitle: 'Auto-approve reports under', + autoApproveReportsUnderDescription: 'Fully compliant expense reports under this amount will be automatically approved.', + randomReportAuditTitle: 'Random report audit', + randomReportAuditDescription: 'Require that some reports be manually approved, even if eligible for auto-approval.', + autoPayApprovedReportsTitle: 'Auto-pay approved reports', + autoPayApprovedReportsSubtitle: 'Configure which expense reports are eligible for auto-pay.', + autoPayApprovedReportsLimitError: (currency?: string) => `Please enter an amount less than ${currency ?? ''}20,000`, + autoPayApprovedReportsLockedSubtitle: 'Go to more features and enable workflows, then add payments to unlock this feature.', + autoPayReportsUnderTitle: 'Auto-pay reports under', + autoPayReportsUnderDescription: 'Fully compliant expense reports under this amount will be automatically paid. ', + unlockFeatureGoToSubtitle: 'Go to', + unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `and enable workflows, then add ${featureName} to unlock this feature.`, }, }, }, @@ -3891,6 +3927,8 @@ export default { greaterThan: (amount?: string) => `Greater than ${amount ?? ''}`, between: (greaterThan: string, lessThan: string) => `Between ${greaterThan} and ${lessThan}`, }, + current: 'Current', + past: 'Past', }, expenseType: 'Expense type', }, @@ -4600,5 +4638,9 @@ export default { } }, genericError: 'Oops, something went wrong. Please try again.', + notAllowed: 'Not so fast...', + notAllowedMessageStart: ({accountOwnerEmail}: AccountOwnerParams) => `You don't have permission to take this action for ${accountOwnerEmail} as a`, + notAllowedMessageHyperLinked: ' limited access', + notAllowedMessageEnd: ' copilot', }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index 9ce98e12b135..174266ba5d66 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3,6 +3,7 @@ import CONST from '@src/CONST'; import type {DelegateRole} from '@src/types/onyx/Account'; import type {ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName} from '@src/types/onyx/Policy'; import type { + AccountOwnerParams, AddressLineParams, AdminCanceledRequestParams, AlreadySignedInParams, @@ -137,6 +138,7 @@ export default { buttonConfirm: 'Ok, entendido', name: 'Nombre', attachment: 'Archivo adjunto', + attachments: 'Archivos adjuntos', from: 'De', to: 'A', in: 'En', @@ -168,6 +170,7 @@ export default { profile: 'Perfil', referral: 'RemisiΓ³n', payments: 'Pagos', + approvals: 'Aprobaciones', wallet: 'Billetera', preferences: 'Preferencias', view: 'Ver', @@ -243,6 +246,7 @@ export default { conjunctionAt: 'a', conjunctionTo: 'a', genericErrorMessage: 'Ups... algo no ha ido bien y la acciΓ³n no se ha podido completar. Por favor, intΓ©ntalo mΓ‘s tarde.', + percentage: 'Porcentaje', error: { invalidAmount: 'Importe no vΓ‘lido.', acceptTerms: 'Debes aceptar los TΓ©rminos de Servicio para continuar.', @@ -378,6 +382,10 @@ export default { import: 'Importar', offlinePrompt: 'No puedes realizar esta acciΓ³n ahora mismo.', outstanding: 'Pendiente', + chats: 'Chats', + unread: 'No leΓ­do', + sent: 'Enviado', + links: 'Enlaces', days: 'dΓ­as', }, connectionComplete: { @@ -856,10 +864,9 @@ export default { genericCreateFailureMessage: 'Error inesperado al enviar este gasto. Por favor, intΓ©ntalo mΓ‘s tarde.', genericCreateInvoiceFailureMessage: 'Error inesperado al enviar la factura. Por favor, intΓ©ntalo de nuevo mΓ‘s tarde.', receiptDeleteFailureError: 'Error inesperado al borrar este recibo. Por favor, vuelve a intentarlo mΓ‘s tarde.', - // eslint-disable-next-line rulesdir/use-periods-for-error-messages receiptFailureMessage: 'El recibo no se subiΓ³.', - saveFileMessage: 'Guarda el archivo ', // eslint-disable-next-line rulesdir/use-periods-for-error-messages + saveFileMessage: 'Guarda el archivo ', loseFileMessage: 'o descarta este error y piΓ©rdelo.', genericDeleteFailureMessage: 'Error inesperado al eliminar este gasto. Por favor, intΓ©ntalo mΓ‘s tarde.', genericEditFailureMessage: 'Error inesperado al editar este gasto. Por favor, intΓ©ntalo mΓ‘s tarde.', @@ -1802,34 +1809,34 @@ export default { hasBeenThrottledError: 'Se ha producido un error al intentar aΓ±adir tu cuenta bancaria. Por favor, espera unos minutos e intΓ©ntalo de nuevo.', hasCurrencyError: 'Β‘Ups! Parece que la moneda de tu espacio de trabajo no estΓ‘ configurada en USD. Por favor, configΓΊrala en USD e intΓ©ntalo nuevamente.', error: { - youNeedToSelectAnOption: 'Debes seleccionar una opciΓ³n para continuar', - noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible', - noBankAccountSelected: 'Por favor, elige una cuenta bancaria', - taxID: 'Por favor, introduce un nΓΊmero de identificaciΓ³n fiscal vΓ‘lido', + youNeedToSelectAnOption: 'Debes seleccionar una opciΓ³n para continuar.', + noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible.', + noBankAccountSelected: 'Por favor, elige una cuenta bancaria.', + taxID: 'Por favor, introduce un nΓΊmero de identificaciΓ³n fiscal vΓ‘lido.', website: 'Por favor, introduce un sitio web vΓ‘lido. El sitio web debe estar en minΓΊsculas.', - zipCode: `Formato de cΓ³digo postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`, - phoneNumber: 'Por favor, introduce un telΓ©fono vΓ‘lido', - companyName: 'Por favor, introduce un nombre comercial legal vΓ‘lido', - addressCity: 'Por favor, introduce una ciudad vΓ‘lida', - addressStreet: 'Por favor, introduce una calle de direcciΓ³n vΓ‘lida que no sea un apartado postal', - addressState: 'Por favor, selecciona un estado', - incorporationDateFuture: 'La fecha de incorporaciΓ³n no puede ser futura', - incorporationState: 'Por favor, selecciona una estado vΓ‘lido', - industryCode: 'Por favor, introduce un cΓ³digo de clasificaciΓ³n de industria vΓ‘lido', - restrictedBusiness: 'Por favor, confirma que la empresa no estΓ‘ en la lista de negocios restringidos', - routingNumber: 'Por favor, introduce un nΓΊmero de ruta vΓ‘lido', - accountNumber: 'Por favor, introduce un nΓΊmero de cuenta vΓ‘lido', - routingAndAccountNumberCannotBeSame: 'Los nΓΊmeros de ruta y de cuenta no pueden ser iguales', - companyType: 'Por favor, selecciona un tipo de compaΓ±Γ­a vΓ‘lido', + zipCode: `Formato de cΓ³digo postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}.`, + phoneNumber: 'Por favor, introduce un telΓ©fono vΓ‘lido.', + companyName: 'Por favor, introduce un nombre comercial legal vΓ‘lido.', + addressCity: 'Por favor, introduce una ciudad vΓ‘lida.', + addressStreet: 'Por favor, introduce una direcciΓ³n vΓ‘lida que no sea un apartado postal.', + addressState: 'Por favor, selecciona un estado.', + incorporationDateFuture: 'La fecha de incorporaciΓ³n no puede ser futura.', + incorporationState: 'Por favor, selecciona una estado vΓ‘lido.', + industryCode: 'Por favor, introduce un cΓ³digo de clasificaciΓ³n de industria vΓ‘lido.', + restrictedBusiness: 'Por favor, confirma que la empresa no estΓ‘ en la lista de negocios restringidos.', + routingNumber: 'Por favor, introduce un nΓΊmero de ruta vΓ‘lido.', + accountNumber: 'Por favor, introduce un nΓΊmero de cuenta vΓ‘lido.', + routingAndAccountNumberCannotBeSame: 'Los nΓΊmeros de ruta y de cuenta no pueden ser iguales.', + companyType: 'Por favor, selecciona un tipo de compaΓ±Γ­a vΓ‘lido.', tooManyAttempts: 'Debido a la gran cantidad de intentos de inicio de sesiΓ³n, esta opciΓ³n ha sido desactivada temporalmente durante 24 horas. Por favor, intΓ©ntalo de nuevo mΓ‘s tarde.', - address: 'Por favor, introduce una direcciΓ³n vΓ‘lida', - dob: 'Por favor, selecciona una fecha de nacimiento vΓ‘lida', - age: 'Debe ser mayor de 18 aΓ±os', - ssnLast4: 'Por favor, introduce los ΓΊltimos 4 dΓ­gitos del nΓΊmero de seguridad social', - firstName: 'Por favor, introduce el nombre', - lastName: 'Por favor, introduce los apellidos', - noDefaultDepositAccountOrDebitCardAvailable: 'Por favor, aΓ±ade una cuenta bancaria para depΓ³sitos o una tarjeta de dΓ©bito', + address: 'Por favor, introduce una direcciΓ³n vΓ‘lida.', + dob: 'Por favor, selecciona una fecha de nacimiento vΓ‘lida.', + age: 'Debe ser mayor de 18 aΓ±os.', + ssnLast4: 'Por favor, introduce los ΓΊltimos 4 dΓ­gitos del nΓΊmero de seguridad social.', + firstName: 'Por favor, introduce el nombre.', + lastName: 'Por favor, introduce los apellidos.', + noDefaultDepositAccountOrDebitCardAvailable: 'Por favor, aΓ±ade una cuenta bancaria para depΓ³sitos o una tarjeta de dΓ©bito.', validationAmounts: 'Los importes de validaciΓ³n que introduciste son incorrectos. Por favor, comprueba tu cuenta bancaria e intΓ©ntalo de nuevo.', }, }, @@ -2978,6 +2985,10 @@ export default { title: 'Integrar', subtitle: 'Conecta Expensify a otros productos financieros populares.', }, + distanceRates: { + title: 'Tasas de distancia', + subtitle: 'AΓ±ade, actualiza y haz cumplir las tasas.', + }, expensifyCard: { title: 'Tarjeta Expensify', subtitle: 'ObtΓ©n informaciΓ³n y control sobre tus gastos', @@ -3031,10 +3042,6 @@ export default { emptyAddedFeedTitle: 'Asignar tarjetas de empresa', emptyAddedFeedDescription: 'Comienza asignando tu primera tarjeta a un miembro.', }, - distanceRates: { - title: 'Tasas de distancia', - subtitle: 'AΓ±ade, actualiza y haz cumplir las tasas.', - }, workflows: { title: 'Flujos de trabajo', subtitle: 'Configura cΓ³mo se aprueba y paga los gastos.', @@ -3781,8 +3788,37 @@ export default { eReceiptsHintLink: 'para la mayorΓ­a de las transacciones en USD', }, expenseReportRules: { + examples: 'Ejemplos:', title: 'Informes de gastos', subtitle: 'Automatiza el cumplimiento, la aprobaciΓ³n y el pago de los informes de gastos.', + customReportNamesTitle: 'Nombres personalizados de informes', + customReportNamesSubtitle: 'Crea nombres personalizados usando nuestras fΓ³rmulas variadas.', + customNameTitle: 'Nombre personalizado', + customNameDescription: 'Elige un nombre personalizado para los informes de gastos usando nuestras ', + customNameDescriptionLink: 'fΓ³rmulas variadas', + customNameInputLabel: 'Nombre', + customNameEmailPhoneExample: 'Correo electrΓ³nico o telΓ©fono del miembro: {report:submit:from}', + customNameStartDateExample: 'Fecha de inicio del informe: {report:startdate}', + customNameWorkspaceNameExample: 'Nombre del espacio de trabajo: {report:policyname}', + customNameReportIDExample: 'ID del informe: {report:id}', + customNameTotalExample: 'Total: {report:total}.', + preventMembersFromChangingCustomNamesTitle: 'Evitar que los miembros cambien los nombres personalizados de los informes', + preventSelfApprovalsTitle: 'Evitar autoaprobaciones', + preventSelfApprovalsSubtitle: 'Evita que los miembros del espacio de trabajo aprueben sus propios informes de gastos.', + autoApproveCompliantReportsTitle: 'AprobaciΓ³n automΓ‘tica de informes conformes', + autoApproveCompliantReportsSubtitle: 'Configura quΓ© informes de gastos pueden aprobarse de forma automΓ‘tica.', + autoApproveReportsUnderTitle: 'Aprobar automΓ‘ticamente informes por debajo de', + autoApproveReportsUnderDescription: 'Los informes de gastos totalmente conformes por debajo de esta cantidad se aprobarΓ‘n automΓ‘ticamente.', + randomReportAuditTitle: 'AuditorΓ­a aleatoria de informes', + randomReportAuditDescription: 'Requiere que algunos informes sean aprobados manualmente, incluso si son elegibles para la aprobaciΓ³n automΓ‘tica.', + autoPayApprovedReportsTitle: 'Pago automΓ‘tico de informes aprobados', + autoPayApprovedReportsSubtitle: 'Configura quΓ© informes de gastos pueden pagarse de forma automΓ‘tica.', + autoPayApprovedReportsLimitError: (currency?: string) => `Por favor, introduce un monto menor a ${currency ?? ''}20,000`, + autoPayApprovedReportsLockedSubtitle: 'Ve a mΓ‘s funciones y habilita flujos de trabajo, luego agrega pagos para desbloquear esta funciΓ³n.', + autoPayReportsUnderTitle: 'Pagar automΓ‘ticamente informes por debajo de', + autoPayReportsUnderDescription: 'Los informes de gastos totalmente conformes por debajo de esta cantidad se pagarΓ‘n automΓ‘ticamente.', + unlockFeatureGoToSubtitle: 'Ir a', + unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `y habilita flujos de trabajo, luego agrega ${featureName} para desbloquear esta funciΓ³n.`, }, }, }, @@ -3942,6 +3978,8 @@ export default { greaterThan: (amount?: string) => `MΓ‘s que ${amount ?? ''}`, between: (greaterThan: string, lessThan: string) => `Entre ${greaterThan} y ${lessThan}`, }, + current: 'Actual', + past: 'Anterior', }, expenseType: 'Tipo de gasto', }, @@ -5117,5 +5155,9 @@ export default { } }, genericError: 'Β‘Ups! Ha ocurrido un error. Por favor, intΓ©ntalo de nuevo.', + notAllowed: 'No tan rΓ‘pido...', + notAllowedMessageStart: ({accountOwnerEmail}: AccountOwnerParams) => `No tienes permiso para realizar esta acciΓ³n para ${accountOwnerEmail}`, + notAllowedMessageHyperLinked: ' copiloto con acceso', + notAllowedMessageEnd: ' limitado', }, } satisfies EnglishTranslation; diff --git a/src/languages/types.ts b/src/languages/types.ts index b79eb033213e..c260e866fbd7 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -315,6 +315,8 @@ type ChangeTypeParams = {oldType: string; newType: string}; type DelegateSubmitParams = {delegateUser: string; originalManager: string}; +type AccountOwnerParams = {accountOwnerEmail: string}; + type ExportedToIntegrationParams = {label: string; markedManually?: boolean; inProgress?: boolean; lastModified?: string}; type IntegrationsMessageParams = { @@ -479,6 +481,7 @@ export type { ChangeTypeParams, ExportedToIntegrationParams, DelegateSubmitParams, + AccountOwnerParams, IntegrationsMessageParams, MarkedReimbursedParams, MarkReimbursedFromIntegrationParams, diff --git a/src/libs/API/parameters/CompleteGuidedSetupParams.ts b/src/libs/API/parameters/CompleteGuidedSetupParams.ts index 8e1273ac6053..0b2c0b66ef0a 100644 --- a/src/libs/API/parameters/CompleteGuidedSetupParams.ts +++ b/src/libs/API/parameters/CompleteGuidedSetupParams.ts @@ -6,6 +6,7 @@ type CompleteGuidedSetupParams = { actorAccountID: number; guidedSetupData: string; engagementChoice: OnboardingPurposeType; + paymentSelected?: string; }; export default CompleteGuidedSetupParams; diff --git a/src/libs/API/parameters/EnablePolicyAutoApprovalOptions.ts b/src/libs/API/parameters/EnablePolicyAutoApprovalOptions.ts new file mode 100644 index 000000000000..4c80b1bf6d7d --- /dev/null +++ b/src/libs/API/parameters/EnablePolicyAutoApprovalOptions.ts @@ -0,0 +1,6 @@ +type EnablePolicyAutoApprovalOptionsParams = { + policyID: string; + enabled: boolean; +}; + +export default EnablePolicyAutoApprovalOptionsParams; diff --git a/src/libs/API/parameters/EnablePolicyAutoReimbursementLimit.ts b/src/libs/API/parameters/EnablePolicyAutoReimbursementLimit.ts new file mode 100644 index 000000000000..acbc2efade41 --- /dev/null +++ b/src/libs/API/parameters/EnablePolicyAutoReimbursementLimit.ts @@ -0,0 +1,6 @@ +type EnablePolicyAutoReimbursementLimitParams = { + policyID: string; + enabled: boolean; +}; + +export default EnablePolicyAutoReimbursementLimitParams; diff --git a/src/libs/API/parameters/EnablePolicyDefaultReportTitle.ts b/src/libs/API/parameters/EnablePolicyDefaultReportTitle.ts new file mode 100644 index 000000000000..2852640ac70a --- /dev/null +++ b/src/libs/API/parameters/EnablePolicyDefaultReportTitle.ts @@ -0,0 +1,6 @@ +type EnablePolicyDefaultReportTitleParams = { + policyID: string; + enable: boolean; +}; + +export default EnablePolicyDefaultReportTitleParams; diff --git a/src/libs/API/parameters/SetPolicyAutoReimbursementLimit.ts b/src/libs/API/parameters/SetPolicyAutoReimbursementLimit.ts new file mode 100644 index 000000000000..7c6a721e03b0 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyAutoReimbursementLimit.ts @@ -0,0 +1,6 @@ +type SetPolicyAutoReimbursementLimitParams = { + policyID: string; + autoReimbursement: {limit: number}; +}; + +export default SetPolicyAutoReimbursementLimitParams; diff --git a/src/libs/API/parameters/SetPolicyAutomaticApprovalLimit.ts b/src/libs/API/parameters/SetPolicyAutomaticApprovalLimit.ts new file mode 100644 index 000000000000..bc9c07570f97 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyAutomaticApprovalLimit.ts @@ -0,0 +1,6 @@ +type SetPolicyAutomaticApprovalLimitParams = { + policyID: string; + limit: number; +}; + +export default SetPolicyAutomaticApprovalLimitParams; diff --git a/src/libs/API/parameters/SetPolicyAutomaticApprovalRate.ts b/src/libs/API/parameters/SetPolicyAutomaticApprovalRate.ts new file mode 100644 index 000000000000..14e331fcb27c --- /dev/null +++ b/src/libs/API/parameters/SetPolicyAutomaticApprovalRate.ts @@ -0,0 +1,6 @@ +type SetPolicyAutomaticApprovalRateParams = { + policyID: string; + auditRate: number; +}; + +export default SetPolicyAutomaticApprovalRateParams; diff --git a/src/libs/API/parameters/SetPolicyDefaultReportTitle.ts b/src/libs/API/parameters/SetPolicyDefaultReportTitle.ts new file mode 100644 index 000000000000..e35dc53e7c7c --- /dev/null +++ b/src/libs/API/parameters/SetPolicyDefaultReportTitle.ts @@ -0,0 +1,6 @@ +type SetPolicyDefaultReportTitleParams = { + policyID: string; + value: string; +}; + +export default SetPolicyDefaultReportTitleParams; diff --git a/src/libs/API/parameters/SetPolicyPreventMemberCreatedTitleParams.ts b/src/libs/API/parameters/SetPolicyPreventMemberCreatedTitleParams.ts new file mode 100644 index 000000000000..92f44aacff41 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyPreventMemberCreatedTitleParams.ts @@ -0,0 +1,6 @@ +type SetPolicyPreventMemberCreatedTitleParams = { + policyID: string; + enforced: boolean; +}; + +export default SetPolicyPreventMemberCreatedTitleParams; diff --git a/src/libs/API/parameters/SetPolicyPreventSelfApproval.ts b/src/libs/API/parameters/SetPolicyPreventSelfApproval.ts new file mode 100644 index 000000000000..7b8398905fee --- /dev/null +++ b/src/libs/API/parameters/SetPolicyPreventSelfApproval.ts @@ -0,0 +1,6 @@ +type SetPolicyPreventSelfApprovalParams = { + policyID: string; + preventSelfApproval: boolean; +}; + +export default SetPolicyPreventSelfApprovalParams; 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/API/parameters/index.ts b/src/libs/API/parameters/index.ts index b58b35e752a1..4cc90f8ae54a 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -276,6 +276,15 @@ export type {CreateWorkspaceApprovalParams, UpdateWorkspaceApprovalParams, Remov export type {default as StartIssueNewCardFlowParams} from './StartIssueNewCardFlowParams'; export type {default as ConnectAsDelegateParams} from './ConnectAsDelegateParams'; export type {default as SetPolicyRulesEnabledParams} from './SetPolicyRulesEnabledParams'; +export type {default as SetPolicyDefaultReportTitleParams} from './SetPolicyDefaultReportTitle'; +export type {default as SetPolicyPreventSelfApprovalParams} from './SetPolicyPreventSelfApproval'; +export type {default as SetPolicyAutomaticApprovalLimitParams} from './SetPolicyAutomaticApprovalLimit'; +export type {default as SetPolicyAutomaticApprovalRateParams} from './SetPolicyAutomaticApprovalRate'; +export type {default as SetPolicyPreventMemberCreatedTitleParams} from './SetPolicyPreventMemberCreatedTitleParams'; +export type {default as SetPolicyAutoReimbursementLimitParams} from './SetPolicyAutoReimbursementLimit'; +export type {default as EnablePolicyAutoReimbursementLimitParams} from './EnablePolicyAutoReimbursementLimit'; +export type {default as EnablePolicyAutoApprovalOptionsParams} from './EnablePolicyAutoApprovalOptions'; +export type {default as EnablePolicyDefaultReportTitleParams} from './EnablePolicyDefaultReportTitle'; export type {default as SetPolicyExpenseMaxAmountNoReceipt} from './SetPolicyExpenseMaxAmountNoReceipt'; export type {default as SetPolicyExpenseMaxAmount} from './SetPolicyExpenseMaxAmount'; export type {default as SetPolicyExpenseMaxAge} from './SetPolicyExpenseMaxAge'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index bc2bc5f342af..5be6cc08d816 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -14,6 +14,15 @@ const WRITE_COMMANDS = { SET_WORKSPACE_APPROVAL_MODE: 'SetWorkspaceApprovalMode', SET_WORKSPACE_PAYER: 'SetWorkspacePayer', SET_WORKSPACE_REIMBURSEMENT: 'SetWorkspaceReimbursement', + SET_POLICY_DEFAULT_REPORT_TITLE: 'SetPolicyDefaultReportTitle', + SET_POLICY_PREVENT_MEMBER_CREATED_TITLE: 'SetPolicyPreventMemberCreatedTitle', + SET_POLICY_PREVENT_SELF_APPROVAL: 'SetPolicyPreventSelfApproval', + SET_POLICY_AUTOMATIC_APPROVAL_LIMIT: 'SetPolicyAutomaticApprovalLimit', + SET_POLICY_AUTOMATIC_APPROVAL_RATE: 'SetPolicyAutomaticApprovalRate', + SET_POLICY_AUTO_REIMBURSEMENT_LIMIT: 'SetPolicyAutoReimbursementLimit', + ENABLE_POLICY_AUTO_REIMBURSEMENT_LIMIT: 'EnablePolicyAutoReimbursementLimit', + ENABLE_POLICY_AUTO_APPROVAL_OPTIONS: 'EnablePolicyAutoApprovalOptions', + ENABLE_POLICY_DEFAULT_REPORT_TITLE: 'EnablePolicyDefaultReportTitle', SET_WORKSPACE_DEFAULT_SPEND_CATEGORY: 'SetPolicyDefaultSpendCategory', DISMISS_REFERRAL_BANNER: 'DismissReferralBanner', UPDATE_PREFERRED_LOCALE: 'UpdatePreferredLocale', @@ -551,6 +560,15 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE]: Parameters.SetWorkspaceApprovalModeParams; [WRITE_COMMANDS.SET_WORKSPACE_PAYER]: Parameters.SetWorkspacePayerParams; [WRITE_COMMANDS.SET_WORKSPACE_REIMBURSEMENT]: Parameters.SetWorkspaceReimbursementParams; + [WRITE_COMMANDS.SET_POLICY_DEFAULT_REPORT_TITLE]: Parameters.SetPolicyDefaultReportTitleParams; + [WRITE_COMMANDS.SET_POLICY_PREVENT_MEMBER_CREATED_TITLE]: Parameters.SetPolicyPreventMemberCreatedTitleParams; + [WRITE_COMMANDS.SET_POLICY_PREVENT_SELF_APPROVAL]: Parameters.SetPolicyPreventSelfApprovalParams; + [WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_LIMIT]: Parameters.SetPolicyAutomaticApprovalLimitParams; + [WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_RATE]: Parameters.SetPolicyAutomaticApprovalRateParams; + [WRITE_COMMANDS.SET_POLICY_AUTO_REIMBURSEMENT_LIMIT]: Parameters.SetPolicyAutoReimbursementLimitParams; + [WRITE_COMMANDS.ENABLE_POLICY_AUTO_REIMBURSEMENT_LIMIT]: Parameters.EnablePolicyAutoReimbursementLimitParams; + [WRITE_COMMANDS.ENABLE_POLICY_AUTO_APPROVAL_OPTIONS]: Parameters.EnablePolicyAutoApprovalOptionsParams; + [WRITE_COMMANDS.ENABLE_POLICY_DEFAULT_REPORT_TITLE]: Parameters.EnablePolicyDefaultReportTitleParams; [WRITE_COMMANDS.SET_WORKSPACE_DEFAULT_SPEND_CATEGORY]: Parameters.SetWorkspaceDefaultSpendCategoryParams; [WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams; [WRITE_COMMANDS.TRACK_EXPENSE]: Parameters.TrackExpenseParams; diff --git a/src/libs/AccountUtils.ts b/src/libs/AccountUtils.ts index b926e20ca59c..b5b58bfbf70c 100644 --- a/src/libs/AccountUtils.ts +++ b/src/libs/AccountUtils.ts @@ -8,4 +8,11 @@ const isValidateCodeFormSubmitting = (account: OnyxEntry) => /** Whether the accound ID is an odd number, useful for A/B testing. */ const isAccountIDOddNumber = (accountID: number) => accountID % 2 === 1; -export default {isValidateCodeFormSubmitting, isAccountIDOddNumber}; +function isDelegateOnlySubmitter(account: OnyxEntry): boolean { + const delegateEmail = account?.delegatedAccess?.delegate; + const delegateRole = account?.delegatedAccess?.delegates?.find((delegate) => delegate.email === delegateEmail)?.role; + + return delegateRole === CONST.DELEGATE_ROLE.SUBMITTER; +} + +export default {isValidateCodeFormSubmitting, isAccountIDOddNumber, isDelegateOnlySubmitter}; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 67ba9f62421d..206bb8509af6 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -49,6 +49,15 @@ function validateAmount(amount: string, decimals: number, amountMaxLength: numbe return amount === '' || decimalNumberRegex.test(amount); } +/** + * Check if percentage is between 0 and 100 + */ +function validatePercentage(amount: string): boolean { + const regexString = '^(100|[0-9]{1,2})$'; + const percentageRegex = new RegExp(regexString, 'i'); + return amount === '' || percentageRegex.test(amount); +} + /** * Replaces each character by calling `convertFn`. If `convertFn` throws an error, then * the original character will be preserved. @@ -80,4 +89,15 @@ function isScanRequest(selectedTab: SelectedTabRequest): boolean { return selectedTab === CONST.TAB_REQUEST.SCAN; } -export {addLeadingZero, isDistanceRequest, isScanRequest, replaceAllDigits, stripCommaFromAmount, stripDecimalsFromAmount, stripSpacesFromAmount, replaceCommasWithPeriod, validateAmount}; +export { + addLeadingZero, + isDistanceRequest, + isScanRequest, + replaceAllDigits, + stripCommaFromAmount, + stripDecimalsFromAmount, + stripSpacesFromAmount, + replaceCommasWithPeriod, + validateAmount, + validatePercentage, +}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index e103e81c47d3..1b2390b17c39 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -460,6 +460,10 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/intacct/import/SageIntacctAddUserDimensionPage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EDIT_USER_DIMENSION]: () => require('../../../../pages/workspace/accounting/intacct/import/SageIntacctEditUserDimensionsPage').default, + [SCREENS.WORKSPACE.RULES_CUSTOM_NAME]: () => require('../../../../pages/workspace/rules/RulesCustomNamePage').default, + [SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER]: () => require('../../../../pages/workspace/rules/RulesAutoApproveReportsUnderPage').default, + [SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT]: () => require('../../../../pages/workspace/rules/RulesRandomReportAuditPage').default, + [SCREENS.WORKSPACE.RULES_AUTO_PAY_REPORTS_UNDER]: () => require('../../../../pages/workspace/rules/RulesAutoPayReportsUnderPage').default, [SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT]: () => require('../../../../pages/workspace/rules/RulesReceiptRequiredAmountPage').default, [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AMOUNT]: () => require('../../../../pages/workspace/rules/RulesMaxExpenseAmountPage').default, [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE]: () => require('../../../../pages/workspace/rules/RulesMaxExpenseAgePage').default, diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx index f432d863704e..d06be872c70a 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx @@ -13,9 +13,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@libs/actions/Session'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import linkingConfig from '@libs/Navigation/linkingConfig'; -import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import Navigation from '@libs/Navigation/Navigation'; import type {RootStackParamList, State} from '@libs/Navigation/types'; import {isCentralPaneName} from '@libs/NavigationUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -26,6 +24,7 @@ import BottomTabAvatar from '@pages/home/sidebar/BottomTabAvatar'; import BottomTabBarFloatingActionButton from '@pages/home/sidebar/BottomTabBarFloatingActionButton'; import variables from '@styles/variables'; import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -95,10 +94,7 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { } Welcome.isOnboardingFlowCompleted({ - onNotCompleted: () => { - const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT.route, linkingConfig.config); - navigationRef.resetRoot(adaptedState); - }, + onNotCompleted: () => OnboardingFlow.startOnboardingFlow(), }); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index ba489d67aeb5..a1aa53bc0b7e 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -15,6 +15,8 @@ import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFl import Log from '@libs/Log'; import {getPathFromURL} from '@libs/Url'; import {updateLastVisitedPath} from '@userActions/App'; +import {updateOnboardingLastVisitedPath} from '@userActions/Welcome'; +import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -59,6 +61,9 @@ function parseAndLogRoute(state: NavigationState) { if (focusedRoute && !CONST.EXCLUDE_FROM_LAST_VISITED_PATH.includes(focusedRoute?.name)) { updateLastVisitedPath(currentPath); + if (currentPath.startsWith(`/${ROUTES.ONBOARDING_ROOT.route}`)) { + updateOnboardingLastVisitedPath(currentPath); + } } // Don't log the route transitions from OldDot because they contain authTokens @@ -99,7 +104,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh // If the user haven't completed the flow, we want to always redirect them to the onboarding flow. // We also make sure that the user is authenticated. if (!NativeModules.HybridAppModule && !hasCompletedGuidedSetupFlow && authenticated && !shouldShowRequire2FAModal) { - const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT.route, linkingConfig.config); + const {adaptedState} = getAdaptedStateFromPath(getOnboardingInitialPath(), linkingConfig.config); return adaptedState; } diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index 22db5deaebfb..ef634d9cb615 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -180,6 +180,10 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT_TYPE, ], [SCREENS.WORKSPACE.RULES]: [ + SCREENS.WORKSPACE.RULES_CUSTOM_NAME, + SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER, + SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT, + SCREENS.WORKSPACE.RULES_AUTO_PAY_REPORTS_UNDER, SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT, SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AMOUNT, SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index a0e1b9a25d35..65fb05f8d008 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -107,8 +107,9 @@ const config: LinkingOptions['config'] = { }, }, [NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: { + // Don't set the initialRouteName, because when the user continues from the last visited onboarding page, + // the onboarding purpose page will be briefly visible. path: ROUTES.ONBOARDING_ROOT.route, - initialRouteName: SCREENS.ONBOARDING.PURPOSE, screens: { [SCREENS.ONBOARDING.PURPOSE]: { path: ROUTES.ONBOARDING_PURPOSE.route, @@ -762,6 +763,18 @@ const config: LinkingOptions['config'] = { taxID: (taxID: string) => decodeURIComponent(taxID), }, }, + [SCREENS.WORKSPACE.RULES_CUSTOM_NAME]: { + path: ROUTES.RULES_CUSTOM_NAME.route, + }, + [SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER]: { + path: ROUTES.RULES_AUTO_APPROVE_REPORTS_UNDER.route, + }, + [SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT]: { + path: ROUTES.RULES_RANDOM_REPORT_AUDIT.route, + }, + [SCREENS.WORKSPACE.RULES_AUTO_PAY_REPORTS_UNDER]: { + path: ROUTES.RULES_AUTO_PAY_REPORTS_UNDER.route, + }, [SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT]: { path: ROUTES.RULES_RECEIPT_REQUIRED_AMOUNT.route, }, diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 10e68ad4a6a8..2c96e5796309 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -47,7 +47,7 @@ type GetAdaptedStateReturnType = { metainfo: Metainfo; }; -type GetAdaptedStateFromPath = (...args: Parameters) => GetAdaptedStateReturnType; +type GetAdaptedStateFromPath = (...args: [...Parameters, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType; // The function getPathFromState that we are using in some places isn't working correctly without defined index. const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1}); @@ -365,7 +365,7 @@ function getAdaptedState(state: PartialState }; } -const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options) => { +const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldReplacePathInNestedState = true) => { const normalizedPath = !path.startsWith('/') ? `/${path}` : path; const pathWithoutPolicyID = getPathWithoutPolicyID(normalizedPath); const isAnonymous = isAnonymousUser(); @@ -374,7 +374,9 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options) => { const policyID = isAnonymous ? undefined : extractPolicyIDFromPath(path); const state = getStateFromPath(pathWithoutPolicyID, options) as PartialState>; - replacePathInNestedState(state, path); + if (shouldReplacePathInNestedState) { + replacePathInNestedState(state, path); + } if (state === undefined) { throw new Error('Unable to parse path'); } diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index c8c2c0f0e41d..ee46cbd238ef 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -712,6 +712,18 @@ type SettingsNavigatorParamList = { policyID: string; cardID: string; }; + [SCREENS.WORKSPACE.RULES_CUSTOM_NAME]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_AUTO_PAY_REPORTS_UNDER]: { + policyID: string; + }; [SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT]: { policyID: string; }; @@ -1332,6 +1344,7 @@ type AuthScreensParamList = CentralPaneScreensParamList & type SearchReportParamList = { [SCREENS.SEARCH.REPORT_RHP]: { reportID: string; + reportActionID?: string; }; }; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index a32a8f0be5ff..d5b95e8e0485 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -7,6 +7,7 @@ import lodashSet from 'lodash/set'; import lodashSortBy from 'lodash/sortBy'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {SetNonNullable} from 'type-fest'; import {FallbackAvatar} from '@components/Icon/Expensicons'; import type {SelectedTagOption} from '@components/TagPicker'; import type {IOUAction} from '@src/CONST'; @@ -306,7 +307,7 @@ Onyx.connect({ const transactionThreadReportID = ReportActionUtils.getOneTransactionThreadReportID(reportID, actions[reportActions[0]]); if (transactionThreadReportID) { const transactionThreadReportActionsArray = Object.values(actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); - sortedReportActions = ReportActionUtils.getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID); + sortedReportActions = ReportActionUtils.getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID, false); } lastReportActions[reportID] = sortedReportActions[0]; @@ -380,8 +381,8 @@ function getAvatarsForAccountIDs(accountIDs: number[], personalDetails: OnyxEntr * Returns the personal details for an array of accountIDs * @returns keys of the object are emails, values are PersonalDetails objects. */ -function getPersonalDetailsForAccountIDs(accountIDs: number[] | undefined, personalDetails: OnyxInputOrEntry): PersonalDetailsList { - const personalDetailsForAccountIDs: PersonalDetailsList = {}; +function getPersonalDetailsForAccountIDs(accountIDs: number[] | undefined, personalDetails: OnyxInputOrEntry): SetNonNullable { + const personalDetailsForAccountIDs: SetNonNullable = {}; if (!personalDetails) { return personalDetailsForAccountIDs; } diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index a2b1f75c3a2d..f62935fc6721 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -986,6 +986,10 @@ function getDomainNameForPolicy(policyID?: string): string { return `${CONST.EXPENSIFY_POLICY_DOMAIN}${policyID}${CONST.EXPENSIFY_POLICY_DOMAIN_EXTENSION}`; } +function getWorkflowApprovalsUnavailable(policy: OnyxEntry) { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL || !!policy?.errorFields?.approvalMode; +} + export { canEditTaxRate, extractPolicyIDFromPath, @@ -1093,6 +1097,7 @@ export { getAllTaxRatesNamesAndKeys as getAllTaxRates, getTagNamesFromTagsLists, getDomainNameForPolicy, + getWorkflowApprovalsUnavailable, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 4d126cf9cbf4..44a2cc62df3c 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -404,6 +404,7 @@ function getCombinedReportActions( transactionThreadReportID: string | null, transactionThreadReportActions: ReportAction[], reportID?: string, + shouldFilterIOUAction = true, ): ReportAction[] { const isSentMoneyReport = reportActions.some((action) => isSentMoneyReportAction(action)); @@ -431,7 +432,7 @@ function getCombinedReportActions( const isSelfDM = report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM; // Filter out request and send money request actions because we don't want to show any preview actions for one transaction reports const filteredReportActions = [...filteredParentReportActions, ...filteredTransactionThreadReportActions].filter((action) => { - if (!isMoneyRequestAction(action)) { + if (!isMoneyRequestAction(action) || !shouldFilterIOUAction) { return true; } const actionType = getOriginalMessage(action)?.type ?? ''; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7b226b2e5c8e..5a30ba661619 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; } @@ -7340,7 +7342,7 @@ function canBeAutoReimbursed(report: OnyxInputOrEntry, policy: OnyxInput } type CurrencyType = TupleToUnion; const reimbursableTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; - const autoReimbursementLimit = policy.autoReimbursementLimit ?? 0; + const autoReimbursementLimit = policy?.autoReimbursement?.limit ?? policy?.autoReimbursementLimit ?? 0; const isAutoReimbursable = isReportInGroupPolicy(report) && policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && @@ -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); @@ -7775,8 +7777,8 @@ function hasMissingInvoiceBankAccount(iouReportID: string): boolean { return invoiceReport?.ownerAccountID === currentUserAccountID && isEmptyObject(getPolicy(invoiceReport?.policyID)?.invoice?.bankAccount ?? {}) && isSettled(iouReportID); } -function isSubmittedExpenseReportManagerWithoutParentAccess(report: OnyxEntry) { - return isExpenseReport(report) && report?.hasParentAccess === false && report?.managerID === currentUserAccountID && isProcessingReport(report); +function isExpenseReportManagerWithoutParentAccess(report: OnyxEntry) { + return isExpenseReport(report) && report?.hasParentAccess === false && report?.managerID === currentUserAccountID; } export { @@ -7975,7 +7977,7 @@ export { isEmptyReport, isRootGroupChat, isExpenseReport, - isSubmittedExpenseReportManagerWithoutParentAccess, + isExpenseReportManagerWithoutParentAccess, isExpenseRequest, isExpensifyOnlyParticipantInReport, isGroupChat, diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index 4c6c382d6224..32fd834c1346 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -3,6 +3,11 @@ // https://peggyjs.org/ + + function buildFilter(operator, left, right) { + return { operator, left, right }; + } + function peg$subclass(child, parent) { function C() { this.constructor = child; } C.prototype = parent.prototype; @@ -176,32 +181,32 @@ function peg$parse(input, options) { var peg$startRuleFunction = peg$parsequery; var peg$c0 = "!="; - var peg$c1 = ">"; - var peg$c2 = ">="; - var peg$c3 = "<"; - var peg$c4 = "<="; - var peg$c5 = "type"; - var peg$c6 = "status"; - var peg$c7 = "date"; - var peg$c8 = "amount"; - var peg$c9 = "expenseType"; - var peg$c10 = "in"; - var peg$c11 = "currency"; - var peg$c12 = "merchant"; - var peg$c13 = "description"; - var peg$c14 = "from"; + var peg$c1 = ">="; + var peg$c2 = ">"; + var peg$c3 = "<="; + var peg$c4 = "<"; + var peg$c5 = "date"; + var peg$c6 = "amount"; + var peg$c7 = "merchant"; + var peg$c8 = "description"; + var peg$c9 = "reportID"; + var peg$c10 = "keyword"; + var peg$c11 = "in"; + var peg$c12 = "currency"; + var peg$c13 = "tag"; + var peg$c14 = "category"; var peg$c15 = "to"; - var peg$c16 = "category"; - var peg$c17 = "tag"; - var peg$c18 = "taxRate"; - var peg$c19 = "cardID"; - var peg$c20 = "reportID"; - var peg$c21 = "keyword"; - var peg$c22 = "sortBy"; - var peg$c23 = "sortOrder"; - var peg$c24 = "policyID"; - var peg$c25 = "has"; - var peg$c26 = "is"; + var peg$c16 = "taxRate"; + var peg$c17 = "cardID"; + var peg$c18 = "from"; + var peg$c19 = "expenseType"; + var peg$c20 = "has"; + var peg$c21 = "is"; + var peg$c22 = "type"; + var peg$c23 = "status"; + var peg$c24 = "sortBy"; + var peg$c25 = "sortOrder"; + var peg$c26 = "policyID"; var peg$c27 = "\""; var peg$r0 = /^[:=]/; @@ -209,118 +214,100 @@ function peg$parse(input, options) { var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',;]/; var peg$r3 = /^[ \t\r\n]/; - var peg$e0 = peg$classExpectation([":", "="], false, false); - var peg$e1 = peg$literalExpectation("!=", false); - var peg$e2 = peg$literalExpectation(">", false); + var peg$e0 = peg$otherExpectation("operator"); + var peg$e1 = peg$classExpectation([":", "="], false, false); + var peg$e2 = peg$literalExpectation("!=", false); var peg$e3 = peg$literalExpectation(">=", false); - var peg$e4 = peg$literalExpectation("<", false); + var peg$e4 = peg$literalExpectation(">", false); var peg$e5 = peg$literalExpectation("<=", false); - var peg$e6 = peg$literalExpectation("type", false); - var peg$e7 = peg$literalExpectation("status", false); + var peg$e6 = peg$literalExpectation("<", false); + var peg$e7 = peg$otherExpectation("key"); var peg$e8 = peg$literalExpectation("date", false); var peg$e9 = peg$literalExpectation("amount", false); - var peg$e10 = peg$literalExpectation("expenseType", false); - var peg$e11 = peg$literalExpectation("in", false); - var peg$e12 = peg$literalExpectation("currency", false); - var peg$e13 = peg$literalExpectation("merchant", false); - var peg$e14 = peg$literalExpectation("description", false); - var peg$e15 = peg$literalExpectation("from", false); - var peg$e16 = peg$literalExpectation("to", false); + var peg$e10 = peg$literalExpectation("merchant", false); + var peg$e11 = peg$literalExpectation("description", false); + var peg$e12 = peg$literalExpectation("reportID", false); + var peg$e13 = peg$literalExpectation("keyword", false); + var peg$e14 = peg$literalExpectation("in", false); + var peg$e15 = peg$literalExpectation("currency", false); + var peg$e16 = peg$literalExpectation("tag", false); var peg$e17 = peg$literalExpectation("category", false); - var peg$e18 = peg$literalExpectation("tag", false); + var peg$e18 = peg$literalExpectation("to", false); var peg$e19 = peg$literalExpectation("taxRate", false); var peg$e20 = peg$literalExpectation("cardID", false); - var peg$e21 = peg$literalExpectation("reportID", false); - var peg$e22 = peg$literalExpectation("keyword", false); - var peg$e23 = peg$literalExpectation("sortBy", false); - 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("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); - if (defaultValues.policyID) { - return applyPolicyID(withDefaults); - } - - return withDefaults; - }; + var peg$e21 = peg$literalExpectation("from", false); + var peg$e22 = peg$literalExpectation("expenseType", false); + var peg$e23 = peg$literalExpectation("has", false); + var peg$e24 = peg$literalExpectation("is", false); + var peg$e25 = peg$otherExpectation("default key"); + var peg$e26 = peg$literalExpectation("type", false); + var peg$e27 = peg$literalExpectation("status", false); + var peg$e28 = peg$literalExpectation("sortBy", false); + var peg$e29 = peg$literalExpectation("sortOrder", false); + var peg$e30 = peg$literalExpectation("policyID", false); + var peg$e31 = peg$otherExpectation("quote"); + var peg$e32 = peg$literalExpectation("\"", false); + var peg$e33 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e34 = peg$otherExpectation("word"); + var peg$e35 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";"], false, false); + var peg$e36 = peg$otherExpectation("whitespace"); + var peg$e37 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); + + var peg$f0 = function(filters) { return applyDefaults(filters); }; var peg$f1 = function(head, tail) { - const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null); + const allFilters = [head, ...tail.map(([_, filter]) => filter)] + .filter(Boolean) + .filter((filter) => filter.right); if (!allFilters.length) { - return null; - } - const keywords = allFilters.filter((filter) => filter.left === "keyword" || filter.right?.left === "keyword") - const nonKeywords = allFilters.filter((filter) => filter.left !== "keyword" && filter.right?.left !== "keyword") - if(!nonKeywords.length){ - return keywords.reduce((result, filter) => buildFilter("or", result, filter)) - } - if(!keywords.length){ - return nonKeywords.reduce((result, filter) => buildFilter("and", result, filter)) - } - - return buildFilter("and", keywords.reduce((result, filter) => buildFilter("or", result, filter)), nonKeywords.reduce((result, filter) => buildFilter("and", result, filter))) - - return allFilters.reduce((result, filter) => buildFilter("and", result, filter)); - }; - var peg$f2 = function(field, op, value) { - if (isDefaultField(field)) { - updateDefaultValues(field, value.trim()); return null; } - if (isPolicyID(field)) { - updateDefaultValues(field, value.trim()); - return null; + const keywords = allFilters.filter( + (filter) => + filter.left === "keyword" || filter.right?.left === "keyword" + ); + const nonKeywords = allFilters.filter( + (filter) => + filter.left !== "keyword" && filter.right?.left !== "keyword" + ); + + const keywordFilter = buildFilter( + "eq", + "keyword", + keywords.map((filter) => filter.right).flat() + ); + if (keywordFilter.right.length > 0) { + nonKeywords.push(keywordFilter); } - - if (!field && !op) { - return buildFilter('eq', 'keyword', value.trim()); + return nonKeywords.reduce((result, filter) => + buildFilter("and", result, filter) + ); + }; + var peg$f2 = function(key, op, value) { + updateDefaultValues(key, value); + }; + var peg$f3 = function(value) { return buildFilter("eq", "keyword", value); }; + var peg$f4 = function(field, op, values) { + return buildFilter(op, field, values); + }; + var peg$f5 = function() { return "eq"; }; + var peg$f6 = function() { return "neq"; }; + var peg$f7 = function() { return "gte"; }; + var peg$f8 = function() { return "gt"; }; + var peg$f9 = function() { return "lte"; }; + var peg$f10 = function() { return "lt"; }; + var peg$f11 = function(parts) { + const value = parts.flat(); + if (value.length > 1) { + return value; } - - const values = value.split(','); - const operatorValue = op ?? 'eq'; - - return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0])); + return value[0]; + }; + var peg$f12 = function(chars) { return chars.join(""); }; + var peg$f13 = function(chars) { + return chars.join("").trim().split(",").filter(Boolean); }; - var peg$f3 = function() { return "eq"; }; - var peg$f4 = function() { return "neq"; }; - var peg$f5 = function() { return "gt"; }; - var peg$f6 = function() { return "gte"; }; - var peg$f7 = function() { return "lt"; }; - var peg$f8 = function() { return "lte"; }; - var peg$f9 = function() { return "type"; }; - var peg$f10 = function() { return "status"; }; - var peg$f11 = function() { return "date"; }; - var peg$f12 = function() { return "amount"; }; - var peg$f13 = function() { return "expenseType"; }; - var peg$f14 = function() { return "in"; }; - var peg$f15 = function() { return "currency"; }; - var peg$f16 = function() { return "merchant"; }; - var peg$f17 = function() { return "description"; }; - var peg$f18 = function() { return "from"; }; - var peg$f19 = function() { return "to"; }; - var peg$f20 = function() { return "category"; }; - var peg$f21 = function() { return "tag"; }; - var peg$f22 = function() { return "taxRate"; }; - var peg$f23 = function() { return "cardID"; }; - var peg$f24 = function() { return "reportID"; }; - var peg$f25 = function() { return "keyword"; }; - var peg$f26 = function() { return "sortBy"; }; - var peg$f27 = function() { return "sortOrder"; }; - var peg$f28 = function() { return "policyID"; }; - var peg$f29 = function() { return "has"; }; - 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(chars) { return chars.join(''); }; - var peg$f34 = function() { return "and"; }; + var peg$f14 = function() { return "and"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -540,24 +527,98 @@ function peg$parse(input, options) { } function peg$parsefilter() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parsestandardFilter(); + if (s1 === peg$FAILED) { + s1 = peg$parsedefaultFilter(); + if (s1 === peg$FAILED) { + s1 = peg$parsefreeTextFilter(); + } + } + if (s1 !== peg$FAILED) { + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsedefaultFilter() { var s0, s1, s2, s3, s4, s5, s6; s0 = peg$currPos; s1 = peg$parse_(); - s2 = peg$parsekey(); - if (s2 === peg$FAILED) { - s2 = null; - } - s3 = peg$parse_(); - s4 = peg$parseoperator(); - if (s4 === peg$FAILED) { - s4 = null; + s2 = peg$parsedefaultKey(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + s4 = peg$parseoperator(); + if (s4 !== peg$FAILED) { + s5 = peg$parse_(); + s6 = peg$parseidentifier(); + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f2(s2, s4, s6); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; } - s5 = peg$parse_(); - s6 = peg$parseidentifier(); - if (s6 !== peg$FAILED) { + + return s0; + } + + function peg$parsefreeTextFilter() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parseidentifier(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); peg$savedPos = s0; - s0 = peg$f2(s2, s4, s6); + s0 = peg$f3(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsestandardFilter() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parsekey(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + s4 = peg$parseoperator(); + if (s4 !== peg$FAILED) { + s5 = peg$parse_(); + s6 = peg$parseidentifier(); + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f4(s2, s4, s6); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } } else { peg$currPos = s0; s0 = peg$FAILED; @@ -569,17 +630,18 @@ function peg$parse(input, options) { function peg$parseoperator() { var s0, s1; + peg$silentFails++; s0 = peg$currPos; s1 = input.charAt(peg$currPos); if (peg$r0.test(s1)) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e0); } + if (peg$silentFails === 0) { peg$fail(peg$e1); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f3(); + s1 = peg$f5(); } s0 = s1; if (s0 === peg$FAILED) { @@ -589,67 +651,67 @@ function peg$parse(input, options) { peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e1); } + if (peg$silentFails === 0) { peg$fail(peg$e2); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f4(); + s1 = peg$f6(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { + if (input.substr(peg$currPos, 2) === peg$c1) { s1 = peg$c1; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e2); } + if (peg$silentFails === 0) { peg$fail(peg$e3); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f5(); + s1 = peg$f7(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c2) { + if (input.charCodeAt(peg$currPos) === 62) { s1 = peg$c2; - peg$currPos += 2; + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e3); } + if (peg$silentFails === 0) { peg$fail(peg$e4); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f6(); + s1 = peg$f8(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { + if (input.substr(peg$currPos, 2) === peg$c3) { s1 = peg$c3; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e4); } + if (peg$silentFails === 0) { peg$fail(peg$e5); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f7(); + s1 = peg$f9(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c4) { + if (input.charCodeAt(peg$currPos) === 60) { s1 = peg$c4; - peg$currPos += 2; + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e5); } + if (peg$silentFails === 0) { peg$fail(peg$e6); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f8(); + s1 = peg$f10(); } s0 = s1; } @@ -657,6 +719,11 @@ function peg$parse(input, options) { } } } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } return s0; } @@ -664,316 +731,150 @@ function peg$parse(input, options) { function peg$parsekey() { var s0, s1; + peg$silentFails++; s0 = peg$currPos; if (input.substr(peg$currPos, 4) === peg$c5) { s1 = peg$c5; peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e6); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f9(); + if (peg$silentFails === 0) { peg$fail(peg$e8); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; + if (s1 === peg$FAILED) { if (input.substr(peg$currPos, 6) === peg$c6) { s1 = peg$c6; peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e7); } + if (peg$silentFails === 0) { peg$fail(peg$e9); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f10(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 4) === peg$c7) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c7) { s1 = peg$c7; - peg$currPos += 4; + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e8); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f11(); + if (peg$silentFails === 0) { peg$fail(peg$e10); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 6) === peg$c8) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 11) === peg$c8) { s1 = peg$c8; - peg$currPos += 6; + peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e9); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f12(); + if (peg$silentFails === 0) { peg$fail(peg$e11); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 11) === peg$c9) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c9) { s1 = peg$c9; - peg$currPos += 11; + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e10); } + if (peg$silentFails === 0) { peg$fail(peg$e12); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f13(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c10) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 7) === peg$c10) { s1 = peg$c10; - peg$currPos += 2; + peg$currPos += 7; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e11); } + if (peg$silentFails === 0) { peg$fail(peg$e13); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f14(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 8) === peg$c11) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 2) === peg$c11) { s1 = peg$c11; - peg$currPos += 8; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e12); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f15(); + if (peg$silentFails === 0) { peg$fail(peg$e14); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; + if (s1 === peg$FAILED) { if (input.substr(peg$currPos, 8) === peg$c12) { s1 = peg$c12; peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e13); } + if (peg$silentFails === 0) { peg$fail(peg$e15); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f16(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 11) === peg$c13) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 3) === peg$c13) { s1 = peg$c13; - peg$currPos += 11; + peg$currPos += 3; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e14); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f17(); + if (peg$silentFails === 0) { peg$fail(peg$e16); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 4) === peg$c14) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c14) { s1 = peg$c14; - peg$currPos += 4; + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e15); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f18(); + if (peg$silentFails === 0) { peg$fail(peg$e17); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; + if (s1 === peg$FAILED) { if (input.substr(peg$currPos, 2) === peg$c15) { s1 = peg$c15; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e16); } + if (peg$silentFails === 0) { peg$fail(peg$e18); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f19(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 8) === peg$c16) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 7) === peg$c16) { s1 = peg$c16; - peg$currPos += 8; + peg$currPos += 7; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e17); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f20(); + if (peg$silentFails === 0) { peg$fail(peg$e19); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 3) === peg$c17) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c17) { s1 = peg$c17; - peg$currPos += 3; + peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e18); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f21(); + if (peg$silentFails === 0) { peg$fail(peg$e20); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 7) === peg$c18) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 4) === peg$c18) { s1 = peg$c18; - peg$currPos += 7; + peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e19); } + if (peg$silentFails === 0) { peg$fail(peg$e21); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f22(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 6) === peg$c19) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 11) === peg$c19) { s1 = peg$c19; - peg$currPos += 6; + peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f23(); + if (peg$silentFails === 0) { peg$fail(peg$e22); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 8) === peg$c20) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 3) === peg$c20) { s1 = peg$c20; - peg$currPos += 8; + peg$currPos += 3; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f24(); + if (peg$silentFails === 0) { peg$fail(peg$e23); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 7) === peg$c21) { - s1 = peg$c21; - peg$currPos += 7; + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 2) === peg$c11) { + s1 = peg$c11; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } + if (peg$silentFails === 0) { peg$fail(peg$e14); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f25(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 6) === peg$c22) { - s1 = peg$c22; - peg$currPos += 6; + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 2) === peg$c21) { + s1 = peg$c21; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f26(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 9) === peg$c23) { - s1 = peg$c23; - peg$currPos += 9; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f27(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 8) === peg$c24) { - s1 = peg$c24; - peg$currPos += 8; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e25); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f28(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 3) === peg$c25) { - s1 = peg$c25; - peg$currPos += 3; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - 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; - } - } - } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } } } @@ -992,6 +893,80 @@ function peg$parse(input, options) { } } } + if (s1 !== peg$FAILED) { + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + + return s0; + } + + function peg$parsedefaultKey() { + var s0, s1; + + peg$silentFails++; + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c22) { + s1 = peg$c22; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e26); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c23) { + s1 = peg$c23; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e27); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c24) { + s1 = peg$c24; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e28); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 9) === peg$c25) { + s1 = peg$c25; + peg$currPos += 9; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e29); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c26) { + s1 = peg$c26; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e30); } + } + } + } + } + } + if (s1 !== peg$FAILED) { + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e25); } + } return s0; } @@ -1018,7 +993,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f31(s1); + s1 = peg$f11(s1); } s0 = s1; @@ -1028,13 +1003,14 @@ function peg$parse(input, options) { function peg$parsequotedString() { var s0, s1, s2, s3; + peg$silentFails++; s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 34) { s1 = peg$c27; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e28); } + if (peg$silentFails === 0) { peg$fail(peg$e32); } } if (s1 !== peg$FAILED) { s2 = []; @@ -1043,7 +1019,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e29); } + if (peg$silentFails === 0) { peg$fail(peg$e33); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -1052,7 +1028,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e29); } + if (peg$silentFails === 0) { peg$fail(peg$e33); } } } if (input.charCodeAt(peg$currPos) === 34) { @@ -1060,11 +1036,11 @@ 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$e32); } } if (s3 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f32(s2); + s0 = peg$f12(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1073,6 +1049,11 @@ function peg$parse(input, options) { peg$currPos = s0; s0 = peg$FAILED; } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e31); } + } return s0; } @@ -1080,6 +1061,7 @@ function peg$parse(input, options) { function peg$parsealphanumeric() { var s0, s1, s2; + peg$silentFails++; s0 = peg$currPos; s1 = []; s2 = input.charAt(peg$currPos); @@ -1087,7 +1069,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e30); } + if (peg$silentFails === 0) { peg$fail(peg$e35); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -1097,7 +1079,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e30); } + if (peg$silentFails === 0) { peg$fail(peg$e35); } } } } else { @@ -1105,9 +1087,14 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f33(s1); + s1 = peg$f13(s1); } s0 = s1; + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e34); } + } return s0; } @@ -1118,7 +1105,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f34(); + s1 = peg$f14(); s0 = s1; return s0; @@ -1134,7 +1121,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e32); } + if (peg$silentFails === 0) { peg$fail(peg$e37); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -1143,39 +1130,28 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e32); } + if (peg$silentFails === 0) { peg$fail(peg$e37); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e31); } + if (peg$silentFails === 0) { peg$fail(peg$e36); } return s0; } const defaultValues = { - "type": "expense", - "status": "all", - "sortBy": "date", - "sortOrder": "desc", + type: "expense", + status: "all", + sortBy: "date", + sortOrder: "desc", }; - function buildFilter(operator, left, right) { - return { operator, left, right }; - } - function applyDefaults(filters) { return { ...defaultValues, - filters - }; - } - - function applyPolicyID(filtersWithDefaults) { - return { - ...filtersWithDefaults, - policyID: filtersWithDefaults.policyID + filters, }; } @@ -1183,14 +1159,6 @@ function peg$parse(input, options) { defaultValues[field] = value; } - function isDefaultField(field) { - return defaultValues.hasOwnProperty(field); - } - - function isPolicyID(field) { - return field === 'policyID'; - } - peg$result = peg$startRuleFunction(); if (options.peg$library) { diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy index 805ee3e668de..bea1e5cfd6ff 100644 --- a/src/libs/SearchParser/searchParser.peggy +++ b/src/libs/SearchParser/searchParser.peggy @@ -5,149 +5,144 @@ // // query: entry point for the parser and rule to process the values returned by the filterList rule. Takes filters as an argument and returns the final AST output. // filterList: rule to process the array of filters returned by the filter rule. It takes head and tail as arguments, filters it for null values and builds the AST. -// filter: rule to build the filter object. It takes field, operator and value as input and returns {operator, left: field, right: value} or null if the left value is a defaultValues +// filter: abstract rule to simplify the filterList rule. It takes all filter types. +// defaultFilter: rule to process the default values returned by the defaultKey rule. It updates the default values object. +// freeTextFilter: rule to process the free text search values returned by the identifier rule. It builds filter Object. +// standardFilter: rule to process the values returned by the key rule. It builds filter Object. // operator: rule to match pre-defined search syntax operators, e.g. !=, >, etc // key: rule to match pre-defined search syntax fields, e.g. amount, merchant, etc +// defaultKey: rule to match pre-defined search syntax fields that are used to update default values, e.g. type, status, etc // identifier: composite rule to match patterns defined by the quotedString and alphanumeric rules // quotedString: rule to match a quoted string pattern, e.g. "this is a quoted string" // alphanumeric: rule to match unquoted alphanumeric characters, e.g. a-z, 0-9, _, @, etc // logicalAnd: rule to match whitespace and return it as a logical 'and' operator // whitespace: rule to match whitespaces -{ - const defaultValues = { - "type": "expense", - "status": "all", - "sortBy": "date", - "sortOrder": "desc", - }; - +// global initializer (code executed only once) +{{ function buildFilter(operator, left, right) { return { operator, left, right }; } +}} + +// per-parser initializer (code executed before every parse) +{ + const defaultValues = { + type: "expense", + status: "all", + sortBy: "date", + sortOrder: "desc", + }; function applyDefaults(filters) { return { ...defaultValues, - filters - }; - } - - function applyPolicyID(filtersWithDefaults) { - return { - ...filtersWithDefaults, - policyID: filtersWithDefaults.policyID + filters, }; } function updateDefaultValues(field, value) { defaultValues[field] = value; } - - function isDefaultField(field) { - return defaultValues.hasOwnProperty(field); - } - - function isPolicyID(field) { - return field === 'policyID'; - } } -query - = _ filters:filterList? _ { - const withDefaults = applyDefaults(filters); - if (defaultValues.policyID) { - return applyPolicyID(withDefaults); - } - - return withDefaults; - } +query = _ filters:filterList? _ { return applyDefaults(filters); } filterList = head:filter tail:(logicalAnd filter)* { - const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null); + const allFilters = [head, ...tail.map(([_, filter]) => filter)] + .filter(Boolean) + .filter((filter) => filter.right); if (!allFilters.length) { - return null; - } - const keywords = allFilters.filter((filter) => filter.left === "keyword" || filter.right?.left === "keyword") - const nonKeywords = allFilters.filter((filter) => filter.left !== "keyword" && filter.right?.left !== "keyword") - if(!nonKeywords.length){ - return keywords.reduce((result, filter) => buildFilter("or", result, filter)) - } - if(!keywords.length){ - return nonKeywords.reduce((result, filter) => buildFilter("and", result, filter)) - } - - return buildFilter("and", keywords.reduce((result, filter) => buildFilter("or", result, filter)), nonKeywords.reduce((result, filter) => buildFilter("and", result, filter))) - - return allFilters.reduce((result, filter) => buildFilter("and", result, filter)); - } - -filter - = _ field:key? _ op:operator? _ value:identifier { - if (isDefaultField(field)) { - updateDefaultValues(field, value.trim()); return null; } - if (isPolicyID(field)) { - updateDefaultValues(field, value.trim()); - return null; + const keywords = allFilters.filter( + (filter) => + filter.left === "keyword" || filter.right?.left === "keyword" + ); + const nonKeywords = allFilters.filter( + (filter) => + filter.left !== "keyword" && filter.right?.left !== "keyword" + ); + + const keywordFilter = buildFilter( + "eq", + "keyword", + keywords.map((filter) => filter.right).flat() + ); + if (keywordFilter.right.length > 0) { + nonKeywords.push(keywordFilter); } + return nonKeywords.reduce((result, filter) => + buildFilter("and", result, filter) + ); + } - if (!field && !op) { - return buildFilter('eq', 'keyword', value.trim()); - } +filter = @(standardFilter / defaultFilter / freeTextFilter) - const values = value.split(','); - const operatorValue = op ?? 'eq'; +defaultFilter + = _ key:defaultKey _ op:operator _ value:identifier { + updateDefaultValues(key, value); + } - return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0])); +freeTextFilter + = _ value:identifier _ { return buildFilter("eq", "keyword", value); } + +standardFilter + = _ field:key _ op:operator _ values:identifier { + return buildFilter(op, field, values); } -operator +operator "operator" = (":" / "=") { return "eq"; } / "!=" { return "neq"; } - / ">" { return "gt"; } / ">=" { return "gte"; } - / "<" { return "lt"; } + / ">" { return "gt"; } / "<=" { return "lte"; } + / "<" { return "lt"; } -key - = "type" { return "type"; } - / "status" { return "status"; } - / "date" { return "date"; } - / "amount" { return "amount"; } - / "expenseType" { return "expenseType"; } - / "in" { return "in"; } - / "currency" { return "currency"; } - / "merchant" { return "merchant"; } - / "description" { return "description"; } - / "from" { return "from"; } - / "to" { return "to"; } - / "category" { return "category"; } - / "tag" { return "tag"; } - / "taxRate" { return "taxRate"; } - / "cardID" { return "cardID"; } - / "reportID" { return "reportID"; } - / "keyword" { return "keyword"; } - / "sortBy" { return "sortBy"; } - / "sortOrder" { return "sortOrder"; } - / "policyID" { return "policyID"; } - / "has" { return "has"; } - / "is" { return "is"; } +key "key" + = @( + "date" + / "amount" + / "merchant" + / "description" + / "reportID" + / "keyword" + / "in" + / "currency" + / "tag" + / "category" + / "to" + / "taxRate" + / "cardID" + / "from" + / "expenseType" + / "has" + / "in" + / "is" + ) + +defaultKey "default key" + = @("type" / "status" / "sortBy" / "sortOrder" / "policyID") identifier - = parts:(quotedString / alphanumeric)+ { return parts.join(''); } + = parts:(quotedString / alphanumeric)+ { + const value = parts.flat(); + if (value.length > 1) { + return value; + } + return value[0]; + } -quotedString - = '"' chars:[^"\r\n]* '"' { return chars.join(''); } +quotedString "quote" = "\"" chars:[^"\r\n]* "\"" { return chars.join(""); } -alphanumeric - = chars:[A-Za-z0-9_@./#&+\-\\',;]+ { return chars.join(''); } +alphanumeric "word" + = chars:[A-Za-z0-9_@./#&+\-\\',;]+ { + return chars.join("").trim().split(",").filter(Boolean); + } -logicalAnd - = _ { return "and"; } +logicalAnd = _ { return "and"; } -_ "whitespace" - = [ \t\r\n]* +_ "whitespace" = [ \t\r\n]* diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index e0a12e967bfa..1b1fcaee8682 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -1,8 +1,9 @@ import type {ValueOf} from 'type-fest'; import type {ASTNode, QueryFilter, QueryFilters, SearchColumnType, SearchQueryJSON, SearchQueryString, SearchStatus, SortOrder} from '@components/Search/types'; +import ChatListItem from '@components/SelectionList/ChatListItem'; import ReportListItem from '@components/SelectionList/Search/ReportListItem'; import TransactionListItem from '@components/SelectionList/Search/TransactionListItem'; -import type {ListItem, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ListItem, ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -76,10 +77,32 @@ function getTransactionItemCommonFormattedProperties( }; } +type ReportKey = `${typeof ONYXKEYS.COLLECTION.REPORT}${string}`; + +type TransactionKey = `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`; + +type ReportActionKey = `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS}${string}`; + +function isReportEntry(key: string): key is ReportKey { + return key.startsWith(ONYXKEYS.COLLECTION.REPORT); +} + +function isTransactionEntry(key: string): key is TransactionKey { + return key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION); +} + +function isReportActionEntry(key: string): key is ReportActionKey { + return key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS); +} + function getShouldShowMerchant(data: OnyxTypes.SearchResults['data']): boolean { - return Object.values(data).some((item) => { - const merchant = item.modifiedMerchant ? item.modifiedMerchant : item.merchant ?? ''; - return merchant !== '' && merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + return Object.keys(data).some((key) => { + if (isTransactionEntry(key)) { + const item = data[key]; + const merchant = item.modifiedMerchant ? item.modifiedMerchant : item.merchant ?? ''; + return merchant !== '' && merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + } + return false; }); } @@ -89,11 +112,16 @@ function isReportListItemType(item: ListItem): item is ReportListItemType { return 'transactions' in item; } -function isTransactionListItemType(item: TransactionListItemType | ReportListItemType): item is TransactionListItemType { +function isTransactionListItemType(item: TransactionListItemType | ReportListItemType | ReportActionListItemType): item is TransactionListItemType { const transactionListItem = item as TransactionListItemType; return transactionListItem.transactionID !== undefined; } +function isReportActionListItemType(item: TransactionListItemType | ReportListItemType | ReportActionListItemType): item is ReportActionListItemType { + const reportActionListItem = item as ReportActionListItemType; + return reportActionListItem.reportActionID !== undefined; +} + function shouldShowYear(data: TransactionListItemType[] | ReportListItemType[] | OnyxTypes.SearchResults['data']): boolean { if (Array.isArray(data)) { return data.some((item: TransactionListItemType | ReportListItemType) => { @@ -110,14 +138,23 @@ function shouldShowYear(data: TransactionListItemType[] | ReportListItemType[] | }); } - for (const [key, transactionItem] of Object.entries(data)) { - if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { - const item = transactionItem as SearchTransaction; + for (const key in data) { + if (isTransactionEntry(key)) { + const item = data[key]; const date = TransactionUtils.getCreated(item); if (DateUtils.doesDateBelongToAPastYear(date)) { return true; } + } else if (isReportActionEntry(key)) { + const item = data[key]; + for (const action of Object.values(item)) { + const date = action.created; + + if (DateUtils.doesDateBelongToAPastYear(date)) { + return true; + } + } } } return false; @@ -128,9 +165,10 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata const doesDataContainAPastYearTransaction = shouldShowYear(data); - return Object.entries(data) - .filter(([key]) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) - .map(([, transactionItem]) => { + return Object.keys(data) + .filter(isTransactionEntry) + .map((key) => { + const transactionItem = data[key]; const from = data.personalDetailsList?.[transactionItem.accountID]; const to = data.personalDetailsList?.[transactionItem.managerID]; @@ -155,6 +193,26 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata }); } +function getReportActionsSections(data: OnyxTypes.SearchResults['data']): ReportActionListItemType[] { + const reportActionItems: ReportActionListItemType[] = []; + for (const key in data) { + if (isReportActionEntry(key)) { + const reportActions = data[key]; + for (const reportAction of Object.values(reportActions)) { + const from = data.personalDetailsList?.[reportAction.accountID]; + reportActionItems.push({ + ...reportAction, + from, + formattedFrom: from?.displayName ?? from?.login ?? '', + date: reportAction.created, + keyForList: reportAction.reportActionID, + }); + } + } + } + return reportActionItems; +} + function getIOUReportName(data: OnyxTypes.SearchResults['data'], reportItem: SearchReport) { const payerPersonalDetails = data.personalDetailsList?.[reportItem.managerID ?? 0]; const payerName = payerPersonalDetails?.displayName ?? payerPersonalDetails?.login ?? translateLocal('common.hidden'); @@ -183,7 +241,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx const reportIDToTransactions: Record = {}; for (const key in data) { - if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { + if (isReportEntry(key)) { const reportItem = {...data[key]}; const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportItem.reportID}`; const transactions = reportIDToTransactions[reportKey]?.transactions ?? []; @@ -192,12 +250,12 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx reportIDToTransactions[reportKey] = { ...reportItem, keyForList: reportItem.reportID, - from: data.personalDetailsList?.[reportItem.accountID], - to: data.personalDetailsList?.[reportItem.managerID], + from: data.personalDetailsList?.[reportItem.accountID ?? -1], + to: data.personalDetailsList?.[reportItem.managerID ?? -1], transactions, reportName: isIOUReport ? getIOUReportName(data, reportItem) : reportItem.reportName, }; - } else if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { + } else if (isTransactionEntry(key)) { const transactionItem = {...data[key]}; const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${transactionItem.reportID}`; @@ -233,15 +291,24 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx return Object.values(reportIDToTransactions); } -function getListItem(status: SearchStatus): ListItemType { +function getListItem(type: SearchDataTypes, status: SearchStatus): ListItemType { + if (type === CONST.SEARCH.DATA_TYPES.CHAT) { + return ChatListItem; + } return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? TransactionListItem : ReportListItem; } -function getSections(status: SearchStatus, data: OnyxTypes.SearchResults['data'], metadata: OnyxTypes.SearchResults['search']) { +function getSections(type: SearchDataTypes, status: SearchStatus, data: OnyxTypes.SearchResults['data'], metadata: OnyxTypes.SearchResults['search']) { + if (type === CONST.SEARCH.DATA_TYPES.CHAT) { + return getReportActionsSections(data); + } return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? getTransactionsSections(data, metadata) : getReportSections(data, metadata); } -function getSortedSections(status: SearchStatus, data: ListItemDataType, sortBy?: SearchColumnType, sortOrder?: SortOrder) { +function getSortedSections(type: SearchDataTypes, status: SearchStatus, data: ListItemDataType, sortBy?: SearchColumnType, sortOrder?: SortOrder) { + if (type === CONST.SEARCH.DATA_TYPES.CHAT) { + return data; + } return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? getSortedTransactionData(data as TransactionListItemType[], sortBy, sortOrder) : getSortedReportData(data as ReportListItemType[]); } @@ -402,10 +469,11 @@ function buildAmountFilterQuery(filterValues: Partial } function sanitizeString(str: string) { - if (str.includes(' ') || str.includes(',')) { - return `"${str}"`; + const safeStr = str; + if (safeStr.includes(' ') || safeStr.includes(',')) { + return `"${safeStr}"`; } - return str; + return safeStr; } function getExpenseTypeTranslationKey(expenseType: ValueOf): TranslationPaths { @@ -456,7 +524,7 @@ function buildQueryStringFromFilters(filterValues: Partial CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); if (keyInCorrectForm) { - return `${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${filterValue as string}`; + return `${filterValue as string}`; } } @@ -507,7 +575,7 @@ function getFilters(queryJSON: SearchQueryJSON) { traverse(node.left); } - if (typeof node?.right === 'object' && node.right) { + if (typeof node?.right === 'object' && node.right && !Array.isArray(node.right)) { traverse(node.right); } @@ -522,10 +590,19 @@ function getFilters(queryJSON: SearchQueryJSON) { // the "?? []" is added only for typescript because otherwise TS throws an error, in newer TS versions this should be fixed const filterArray = filters[nodeKey] ?? []; - filterArray.push({ - operator: node.operator, - value: node.right as string | number, - }); + if (!Array.isArray(node.right)) { + filterArray.push({ + operator: node.operator, + value: node.right as string | number, + }); + } else { + node.right.forEach((element) => { + filterArray.push({ + operator: node.operator, + value: element as string | number, + }); + }); + } } if (queryJSON.filters) { @@ -562,7 +639,7 @@ function buildFilterString(filterName: string, queryFilters: QueryFilter[]) { if ((queryFilter.operator === 'eq' && queryFilters[index - 1]?.operator === 'eq') || (queryFilter.operator === 'neq' && queryFilters[index - 1]?.operator === 'neq')) { filterValueString += ` ${sanitizeString(queryFilter.value.toString())}`; } else { - filterValueString += ` ${filterName}${operatorToSignMap[queryFilter.operator]}${queryFilter.value}`; + filterValueString += ` ${filterName}${operatorToSignMap[queryFilter.operator]}${sanitizeString(queryFilter.value.toString())}`; } }); @@ -612,6 +689,7 @@ export { isReportListItemType, isSearchResultsEmpty, isTransactionListItemType, + isReportActionListItemType, normalizeQuery, shouldShowYear, buildCannedSearchQuery, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 0e8447635098..1e5965be8d5e 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -129,10 +129,10 @@ function getOrderedReportIDs( return; } const isSystemChat = ReportUtils.isSystemChat(report); - const isSubmittedExpenseReportManagerWithoutParentAccess = ReportUtils.isSubmittedExpenseReportManagerWithoutParentAccess(report); + const isExpenseReportManagerWithoutParentAccess = ReportUtils.isExpenseReportManagerWithoutParentAccess(report); const shouldOverrideHidden = // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - hasValidDraftComment(report.reportID) || hasErrorsOtherThanFailedReceipt || isFocused || isSystemChat || report.isPinned || isSubmittedExpenseReportManagerWithoutParentAccess; + hasValidDraftComment(report.reportID) || hasErrorsOtherThanFailedReceipt || isFocused || isSystemChat || report.isPinned || isExpenseReportManagerWithoutParentAccess; if (isHidden && !shouldOverrideHidden) { return; } @@ -296,7 +296,7 @@ function getOptionData({ const participantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report); const visibleParticipantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report, true); - const participantPersonalDetailList = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails)) as PersonalDetails[]; + const participantPersonalDetailList = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails)); const personalDetail = participantPersonalDetailList[0] ?? {}; const hasErrors = Object.keys(result.allReportErrors ?? {}).length !== 0; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 29d481737790..f3c406a8f04e 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -41,12 +41,14 @@ import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralP import Navigation from '@libs/Navigation/Navigation'; import * as NextStepUtils from '@libs/NextStepUtils'; import {rand64} from '@libs/NumberUtils'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportConnection from '@libs/ReportConnection'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as SessionUtils from '@libs/SessionUtils'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import {getCurrency, getTransaction} from '@libs/TransactionUtils'; @@ -272,6 +274,18 @@ Onyx.connect({ callback: (value) => (activePolicyID = value), }); +let introSelected: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_INTRO_SELECTED, + callback: (value) => (introSelected = value), +}); + +let personalDetailsList: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (value) => (personalDetailsList = value), +}); + /** * Find the report preview action from given chat report and iou report */ @@ -3666,6 +3680,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 +3820,7 @@ function trackExpense( transactionThreadReportID: transactionThreadReportID ?? '-1', createdReportActionIDForThread: createdReportActionIDForThread ?? '-1', waypoints: validWaypoints ? JSON.stringify(validWaypoints) : undefined, + customUnitRateID, }; if (actionableWhisperReportActionIDParam) { parameters.actionableWhisperReportActionID = actionableWhisperReportActionIDParam; @@ -7389,12 +7405,52 @@ function cancelPayment(expenseReport: OnyxEntry, chatReport: O ); } +/** + * Completes onboarding for invite link flow based on the selected payment option + * + * @param paymentSelected based on which we choose the onboarding choice and concierge message + */ +function completePaymentOnboarding(paymentSelected: ValueOf) { + const isInviteOnboardingComplete = introSelected?.isInviteOnboardingComplete ?? false; + + if (isInviteOnboardingComplete || !introSelected?.choice) { + return; + } + + const session = SessionUtils.getSession(); + + const personalDetailsListValues = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(session?.accountID ? [session.accountID] : [], personalDetailsList)); + const personalDetails = personalDetailsListValues[0] ?? {}; + + let onboardingPurpose = introSelected.choice; + if (introSelected.inviteType === CONST.ONBOARDING_INVITE_TYPES.IOU && paymentSelected === CONST.IOU.PAYMENT_SELECTED.BBA) { + onboardingPurpose = CONST.ONBOARDING_CHOICES.MANAGE_TEAM; + } + + if (introSelected.inviteType === CONST.ONBOARDING_INVITE_TYPES.INVOICE && paymentSelected !== CONST.IOU.PAYMENT_SELECTED.BBA) { + onboardingPurpose = CONST.ONBOARDING_CHOICES.CHAT_SPLIT; + } + + Report.completeOnboarding( + onboardingPurpose, + CONST.ONBOARDING_MESSAGES[onboardingPurpose], + { + firstName: personalDetails.firstName ?? '', + lastName: personalDetails.lastName ?? '', + }, + paymentSelected, + ); +} + function payMoneyRequest(paymentType: PaymentMethodType, chatReport: OnyxTypes.Report, iouReport: OnyxTypes.Report, full = true) { if (chatReport.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(chatReport.policyID)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(chatReport.policyID)); return; } + const paymentSelected = paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? CONST.IOU.PAYMENT_SELECTED.BBA : CONST.IOU.PAYMENT_SELECTED.PBA; + completePaymentOnboarding(paymentSelected); + const recipient = {accountID: iouReport.ownerAccountID}; const {params, optimisticData, successData, failureData} = getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentType, full); @@ -7414,6 +7470,9 @@ function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes. params: {reportActionID}, } = getPayMoneyRequestParams(chatReport, invoiceReport, recipient, paymentMethodType, true, payAsBusiness); + const paymentSelected = paymentMethodType === CONST.IOU.PAYMENT_TYPE.VBBA ? CONST.IOU.PAYMENT_SELECTED.BBA : CONST.IOU.PAYMENT_SELECTED.PBA; + completePaymentOnboarding(paymentSelected); + const params: PayInvoiceParams = { reportID: invoiceReport.reportID, reportActionID, @@ -8009,6 +8068,7 @@ export { getIOURequestPolicyID, initMoneyRequest, navigateToStartStepIfScanFileCannotBeRead, + completePaymentOnboarding, payInvoice, payMoneyRequest, putOnHold, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 018089ed62f0..a61306616b14 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -13,8 +13,11 @@ import type { DeleteWorkspaceAvatarParams, DeleteWorkspaceParams, DisablePolicyBillableModeParams, + EnablePolicyAutoApprovalOptionsParams, + EnablePolicyAutoReimbursementLimitParams, EnablePolicyCompanyCardsParams, EnablePolicyConnectionsParams, + EnablePolicyDefaultReportTitleParams, EnablePolicyExpensifyCardsParams, EnablePolicyInvoicingParams, EnablePolicyReportFieldsParams, @@ -33,20 +36,26 @@ import type { OpenWorkspaceParams, OpenWorkspaceReimburseViewParams, RequestExpensifyCardLimitIncreaseParams, + SetPolicyAutomaticApprovalLimitParams, + SetPolicyAutomaticApprovalRateParams, + SetPolicyAutoReimbursementLimitParams, SetPolicyBillableModeParams, + SetPolicyDefaultReportTitleParams, + SetPolicyPreventMemberCreatedTitleParams, + SetPolicyPreventSelfApprovalParams, + SetPolicyRulesEnabledParams, SetWorkspaceApprovalModeParams, SetWorkspaceAutoReportingFrequencyParams, SetWorkspaceAutoReportingMonthlyOffsetParams, SetWorkspacePayerParams, SetWorkspaceReimbursementParams, + UpdatePolicyAddressParams, UpdateWorkspaceAvatarParams, UpdateWorkspaceCustomUnitAndRateParams, UpdateWorkspaceDescriptionParams, UpdateWorkspaceGeneralSettingsParams, UpgradeToCorporateParams, } from '@libs/API/parameters'; -import type SetPolicyRulesEnabledParams from '@libs/API/parameters/SetPolicyRulesEnabledParams'; -import type UpdatePolicyAddressParams from '@libs/API/parameters/UpdatePolicyAddressParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; @@ -377,6 +386,8 @@ function deleteWorkspace(policyID: string, policyName: string) { statusNum, oldPolicyName, policyName: report?.policyName, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: null, }, }); }); @@ -3864,6 +3875,660 @@ function getAdminPoliciesConnectedToNetSuite(): Policy[] { return Object.values(allPolicies ?? {}).filter((policy): policy is Policy => !!policy && policy.role === CONST.POLICY.ROLE.ADMIN && !!policy?.connections?.netsuite); } +/** + * Call the API to enable custom report title for the reports in the given policy + * @param policyID - id of the policy to apply the limit to + * @param enabled - whether custom report title for the reports is enabled in the given policy + */ +function enablePolicyDefaultReportTitle(policyID: string, enabled: boolean) { + const policy = getPolicy(policyID); + + if (enabled === policy?.shouldShowCustomReportTitleOption) { + return; + } + + const previousReportTitleField = policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID] ?? {}; + const titleFieldValues = enabled ? {} : {fieldList: {[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {...previousReportTitleField, defaultValue: CONST.POLICY.DEFAULT_REPORT_NAME_PATTERN}}}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + shouldShowCustomReportTitleOption: enabled, + ...titleFieldValues, + pendingFields: { + shouldShowCustomReportTitleOption: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + shouldShowCustomReportTitleOption: null, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + shouldShowCustomReportTitleOption: !!policy?.shouldShowCustomReportTitleOption, + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: previousReportTitleField, + }, + pendingFields: { + shouldShowCustomReportTitleOption: null, + }, + errorFields: { + shouldShowCustomReportTitleOption: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: EnablePolicyDefaultReportTitleParams = { + enable: enabled, + policyID, + }; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_DEFAULT_REPORT_TITLE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to set default report title pattern for the given policy + * @param policyID - id of the policy to apply the naming pattern to + * @param customName - name pattern to be used for the reports + */ +function setPolicyDefaultReportTitle(policyID: string, customName: string) { + const policy = getPolicy(policyID); + + if (customName === policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]?.defaultValue) { + return; + } + + const previousReportTitleField = policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID] ?? {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: { + defaultValue: customName, + pendingFields: {defaultValue: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {pendingFields: {defaultValue: null}}, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {...previousReportTitleField, pendingFields: {defaultValue: null}}, + }, + errorFields: { + fieldList: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyDefaultReportTitleParams = { + value: customName, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_DEFAULT_REPORT_TITLE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable or disable enforcing the naming pattern for member created reports on a policy + * @param policyID - id of the policy to apply the naming pattern to + * @param enforced - flag whether to enforce policy name + */ +function setPolicyPreventMemberCreatedTitle(policyID: string, enforced: boolean) { + const policy = getPolicy(policyID); + + if (!enforced === policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID].deletable) { + return; + } + + const previousReportTitleField = policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID] ?? {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {...previousReportTitleField, deletable: !enforced, pendingFields: {deletable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {pendingFields: {deletable: null}}, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {...previousReportTitleField, pendingFields: {deletable: null}}, + }, + errorFields: { + fieldList: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyPreventMemberCreatedTitleParams = { + enforced, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_PREVENT_MEMBER_CREATED_TITLE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable or disable self approvals for the reports + * @param policyID - id of the policy to apply the naming pattern to + * @param preventSelfApproval - flag whether to prevent workspace members from approving their own expense reports + */ +function setPolicyPreventSelfApproval(policyID: string, preventSelfApproval: boolean) { + const policy = getPolicy(policyID); + + if (preventSelfApproval === policy?.preventSelfApproval) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + preventSelfApproval, + pendingFields: { + preventSelfApproval: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + preventSelfApproval: null, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + preventSelfApproval: policy?.preventSelfApproval ?? false, + pendingFields: { + preventSelfApproval: null, + }, + errorFields: { + preventSelfApproval: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyPreventSelfApprovalParams = { + preventSelfApproval, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_PREVENT_SELF_APPROVAL, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to apply automatic approval limit for the given policy + * @param policyID - id of the policy to apply the limit to + * @param limit - max amount for auto-approval of the reports in the given policy + */ +function setPolicyAutomaticApprovalLimit(policyID: string, limit: string) { + const policy = getPolicy(policyID); + + const fallbackLimit = limit === '' ? '0' : limit; + const parsedLimit = CurrencyUtils.convertToBackendAmount(parseFloat(fallbackLimit)); + + if (parsedLimit === policy?.autoApproval?.limit ?? CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + limit: parsedLimit, + pendingFields: {limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + pendingFields: { + limit: null, + }, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + limit: policy?.autoApproval?.limit ?? CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS, + pendingFields: { + limit: null, + }, + }, + errorFields: { + autoApproval: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyAutomaticApprovalLimitParams = { + limit: parsedLimit, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_LIMIT, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to set the audit rate for the given policy + * @param policyID - id of the policy to apply the limit to + * @param auditRate - percentage of the reports to be qualified for a random audit + */ +function setPolicyAutomaticApprovalRate(policyID: string, auditRate: string) { + const policy = getPolicy(policyID); + const fallbackAuditRate = auditRate === '' ? '0' : auditRate; + const parsedAuditRate = parseInt(fallbackAuditRate, 10); + + if (parsedAuditRate === policy?.autoApproval?.auditRate ?? CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + auditRate: parsedAuditRate, + pendingFields: { + auditRate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + pendingFields: { + auditRate: null, + }, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + auditRate: policy?.autoApproval?.auditRate ?? CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE, + pendingFields: { + auditRate: null, + }, + }, + errorFields: { + autoApproval: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyAutomaticApprovalRateParams = { + auditRate: parsedAuditRate, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_RATE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable auto-approval for the reports in the given policy + * @param policyID - id of the policy to apply the limit to + * @param enabled - whether auto-approve for the reports is enabled in the given policy + */ +function enableAutoApprovalOptions(policyID: string, enabled: boolean) { + const policy = getPolicy(policyID); + + if (enabled === policy?.shouldShowAutoApprovalOptions) { + return; + } + + const autoApprovalCleanupValues = !enabled + ? { + pendingFields: { + limit: null, + auditRate: null, + }, + } + : {}; + const autoApprovalValues = !enabled ? {auditRate: CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE, limit: CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS} : {}; + const autoApprovalFailureValues = !enabled ? {autoApproval: {limit: policy?.autoApproval?.limit, auditRate: policy?.autoApproval?.auditRate, ...autoApprovalCleanupValues}} : {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + ...autoApprovalValues, + pendingFields: { + limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + auditRate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + shouldShowAutoApprovalOptions: enabled, + pendingFields: { + shouldShowAutoApprovalOptions: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: {...autoApprovalCleanupValues}, + pendingFields: { + shouldShowAutoApprovalOptions: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + ...autoApprovalFailureValues, + shouldShowAutoApprovalOptions: policy?.shouldShowAutoApprovalOptions, + pendingFields: { + shouldShowAutoApprovalOptions: null, + }, + }, + }, + ]; + + const parameters: EnablePolicyAutoApprovalOptionsParams = { + enabled, + policyID, + }; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_AUTO_APPROVAL_OPTIONS, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to set the limit for auto-payments in the given policy + * @param policyID - id of the policy to apply the limit to + * @param limit - max amount for auto-payment for the reports in the given policy + */ +function setPolicyAutoReimbursementLimit(policyID: string, limit: string) { + const policy = getPolicy(policyID); + const fallbackLimit = limit === '' ? '0' : limit; + const parsedLimit = CurrencyUtils.convertToBackendAmount(parseFloat(fallbackLimit)); + + if (parsedLimit === policy?.autoReimbursement?.limit ?? CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: { + limit: parsedLimit, + pendingFields: { + limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: { + limit: parsedLimit, + pendingFields: { + limit: null, + }, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: {limit: policy?.autoReimbursement?.limit ?? policy?.autoReimbursementLimit, pendingFields: {limit: null}}, + errorFields: { + autoReimbursement: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyAutoReimbursementLimitParams = { + autoReimbursement: {limit: parsedLimit}, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_AUTO_REIMBURSEMENT_LIMIT, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable auto-payment for the reports in the given policy + * @param policyID - id of the policy to apply the limit to + * @param enabled - whether auto-payment for the reports is enabled in the given policy + */ +function enablePolicyAutoReimbursementLimit(policyID: string, enabled: boolean) { + const policy = getPolicy(policyID); + + if (enabled === policy?.shouldShowAutoReimbursementLimitOption) { + return; + } + + const autoReimbursementCleanupValues = !enabled + ? { + pendingFields: { + limit: null, + }, + } + : {}; + const autoReimbursementFailureValues = !enabled ? {autoReimbursement: {limit: policy?.autoReimbursement?.limit, ...autoReimbursementCleanupValues}} : {}; + const autoReimbursementValues = !enabled ? {limit: CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS} : {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: { + ...autoReimbursementValues, + pendingFields: { + limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + shouldShowAutoReimbursementLimitOption: enabled, + pendingFields: { + shouldShowAutoReimbursementLimitOption: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: {...autoReimbursementCleanupValues}, + pendingFields: { + shouldShowAutoReimbursementLimitOption: null, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + ...autoReimbursementFailureValues, + shouldShowAutoReimbursementLimitOption: policy?.shouldShowAutoReimbursementLimitOption, + pendingFields: { + shouldShowAutoReimbursementLimitOption: null, + }, + }, + }, + ]; + + const parameters: EnablePolicyAutoReimbursementLimitParams = { + enabled, + policyID, + }; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_AUTO_REIMBURSEMENT_LIMIT, parameters, { + optimisticData, + successData, + failureData, + }); +} + function setWorkspaceCompanyCardFeedName(policyID: string, workspaceAccountID: number, bankName: string, userDefinedName: string) { const authToken = NetworkStore.getAuthToken(); const onyxData: OnyxData = { @@ -3914,6 +4579,7 @@ function setWorkspaceCompanyCardTransactionLiability(workspaceAccountID: number, API.write(WRITE_COMMANDS.SET_COMPANY_CARD_TRANSACTION_LIABILITY, parameters, onyxData); } + function deleteWorkspaceCompanyCardFeed(policyID: string, workspaceAccountID: number, bankName: string) { const authToken = NetworkStore.getAuthToken(); @@ -4032,6 +4698,15 @@ export { hasInvoicingDetails, clearAllPolicies, enablePolicyRules, + setPolicyDefaultReportTitle, + setPolicyPreventMemberCreatedTitle, + setPolicyPreventSelfApproval, + setPolicyAutomaticApprovalLimit, + setPolicyAutomaticApprovalRate, + setPolicyAutoReimbursementLimit, + enablePolicyDefaultReportTitle, + enablePolicyAutoReimbursementLimit, + enableAutoApprovalOptions, setPolicyMaxExpenseAmountNoReceipt, setPolicyMaxExpenseAmount, setPolicyMaxExpenseAge, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 923d23ca9636..e9703d48d947 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 { @@ -111,6 +110,7 @@ import * as Modal from './Modal'; import navigateFromNotification from './navigateFromNotification'; import * as Session from './Session'; import * as Welcome from './Welcome'; +import * as OnboardingFlow from './Welcome/OnboardingFlow'; type SubscriberCallback = (isFromCurrentUser: boolean, reportActionID: string | undefined) => void; @@ -2164,17 +2164,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); @@ -2704,7 +2693,9 @@ function openReportFromDeepLink(url: string) { // We need skip deeplinking if the user hasn't completed the guided setup flow. if (!hasCompletedGuidedSetupFlow) { - Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT.getRoute())}); + Welcome.isOnboardingFlowCompleted({ + onNotCompleted: () => OnboardingFlow.startOnboardingFlow(), + }); return; } @@ -3328,14 +3319,9 @@ function completeOnboarding( }, adminsChatReportID?: string, onboardingPolicyID?: string, + paymentSelected?: 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 ?? {}; @@ -3359,7 +3345,7 @@ function completeOnboarding( let videoCommentAction: OptimisticAddCommentReportAction | null = null; let videoMessage: AddCommentOrAttachementParams | null = null; - if (data.video) { + if ('video' in data && data.video) { const videoComment = ReportUtils.buildOptimisticAddCommentReportAction(CONST.ATTACHMENT_MESSAGE_TEXT, undefined, actorAccountID, 2); videoCommentAction = videoComment.reportAction; videoMessage = { @@ -3388,7 +3374,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 +3438,6 @@ function completeOnboarding( }, isOptimisticReport: true, managerID: currentUserAccountID, - permissions: targetChatReport?.permissions ?? fallbackPermission, }, }, { @@ -3642,7 +3627,7 @@ function completeOnboarding( {type: 'message', ...textMessage}, ]; - if (data.video && videoCommentAction && videoMessage) { + if ('video' in data && data.video && videoCommentAction && videoMessage) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, @@ -3680,6 +3665,7 @@ function completeOnboarding( lastName, actorAccountID, guidedSetupData: JSON.stringify(guidedSetupData), + paymentSelected, }; API.write(WRITE_COMMANDS.COMPLETE_GUIDED_SETUP, parameters, {optimisticData, successData, failureData}); @@ -4090,6 +4076,8 @@ function markAsManuallyExported(reportID: string, connectionName: ConnectionName API.write(WRITE_COMMANDS.MARK_AS_EXPORTED, params, {optimisticData, successData, failureData}); } +export type {Video}; + export { searchInServer, addComment, @@ -4111,7 +4099,6 @@ export { saveReportActionDraft, deleteReportComment, navigateToConciergeChat, - navigateToSystemChat, addPolicyReport, deleteReport, navigateToConciergeChatAndDeleteReport, diff --git a/src/libs/actions/Timing.ts b/src/libs/actions/Timing.ts index 6244ab2a609b..edb751b33a4b 100644 --- a/src/libs/actions/Timing.ts +++ b/src/libs/actions/Timing.ts @@ -20,13 +20,11 @@ let timestampData: Record = {}; * @param shouldUseFirebase - adds an additional trace in Firebase */ function start(eventName: string, shouldUseFirebase = true) { - timestampData[eventName] = {startTime: Date.now(), shouldUseFirebase}; - - if (!shouldUseFirebase) { - return; + if (shouldUseFirebase) { + Firebase.startTrace(eventName); } - Firebase.startTrace(eventName); + timestampData[eventName] = {startTime: performance.now(), shouldUseFirebase}; } /** @@ -42,13 +40,14 @@ function end(eventName: string, secondaryName = '', maxExecutionTime = 0) { } const {startTime, shouldUseFirebase} = timestampData[eventName]; - Environment.getEnvironment().then((envName) => { - const eventTime = Date.now() - startTime; - if (shouldUseFirebase) { - Firebase.stopTrace(eventName); - } + const eventTime = performance.now() - startTime; + if (shouldUseFirebase) { + Firebase.stopTrace(eventName); + } + + Environment.getEnvironment().then((envName) => { const baseEventName = `${envName}.new.expensify.${eventName}`; const grafanaEventName = secondaryName ? `${baseEventName}.${secondaryName}` : baseEventName; diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts new file mode 100644 index 000000000000..4e780090299d --- /dev/null +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -0,0 +1,126 @@ +import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; +import type {NavigationState, PartialState} from '@react-navigation/native'; +import Onyx from 'react-native-onyx'; +import linkingConfig from '@libs/Navigation/linkingConfig'; +import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import type {NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +let selectedPurpose: string | undefined = ''; +Onyx.connect({ + key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, + callback: (value) => { + selectedPurpose = value; + }, +}); + +let onboardingInitialPath = ''; +const onboardingLastVisitedPathConnection = Onyx.connect({ + key: ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, + callback: (value) => { + if (value === undefined) { + return; + } + onboardingInitialPath = value; + Onyx.disconnect(onboardingLastVisitedPathConnection); + }, +}); + +/** + * Build the correct stack order for `onboardingModalNavigator`, + * based on onboarding data (currently from the selected purpose). + * The correct stack order will ensure that navigation and + * the `goBack` navigatoin work properly. + */ +function adaptOnboardingRouteState() { + const currentRoute: NavigationPartialRoute | undefined = navigationRef.getCurrentRoute(); + if (!currentRoute || currentRoute?.name === SCREENS.ONBOARDING.PURPOSE) { + return; + } + + const rootState = navigationRef.getRootState(); + const adaptedState = rootState; + const lastRouteIndex = (adaptedState?.routes?.length ?? 0) - 1; + const onBoardingModalNavigatorState = adaptedState?.routes[lastRouteIndex]?.state; + if (!onBoardingModalNavigatorState || onBoardingModalNavigatorState?.routes?.length > 1) { + return; + } + + let adaptedOnboardingModalNavigatorState = {} as Readonly>; + if (currentRoute?.name === SCREENS.ONBOARDING.PERSONAL_DETAILS && selectedPurpose === CONST.ONBOARDING_CHOICES.MANAGE_TEAM) { + adaptedOnboardingModalNavigatorState = { + index: 2, + routes: [ + { + name: SCREENS.ONBOARDING.PURPOSE, + params: currentRoute?.params, + }, + { + name: SCREENS.ONBOARDING.WORK, + params: currentRoute?.params, + }, + {...currentRoute}, + ], + } as Readonly>; + } else { + adaptedOnboardingModalNavigatorState = { + index: 1, + routes: [ + { + name: SCREENS.ONBOARDING.PURPOSE, + params: currentRoute?.params, + }, + {...currentRoute}, + ], + } as Readonly>; + } + + adaptedState.routes[lastRouteIndex].state = adaptedOnboardingModalNavigatorState; + navigationRef.resetRoot(adaptedState); +} + +/** + * Start a new onboarding flow or continue from the last visited onboarding page. + */ +function startOnboardingFlow() { + const currentRoute = navigationRef.getCurrentRoute(); + const {adaptedState} = getAdaptedStateFromPath(getOnboardingInitialPath(), linkingConfig.config, false); + const focusedRoute = findFocusedRoute(adaptedState as PartialState>); + if (focusedRoute?.name === currentRoute?.name) { + return; + } + navigationRef.resetRoot(adaptedState); +} + +function getOnboardingInitialPath(): string { + const state = getStateFromPath(onboardingInitialPath, linkingConfig.config); + if (state?.routes?.at(-1)?.name !== NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR) { + return `/${ROUTES.ONBOARDING_ROOT.route}`; + } + + return onboardingInitialPath; +} + +function clearInitialPath() { + onboardingInitialPath = ''; +} + +/** + * Onboarding flow: Go back to the previous page. + * Since there is no `initialRoute` for `onBoardingModalNavigator`, + * firstly, adjust the current onboarding modal navigator to establish the correct stack order. + * Then, navigate to the previous onboarding page using the usual `goBack` function. + */ +function goBack() { + adaptOnboardingRouteState(); + Navigation.isNavigationReady().then(() => { + Navigation.goBack(); + }); +} + +export {getOnboardingInitialPath, startOnboardingFlow, clearInitialPath, goBack}; diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome/index.ts similarity index 92% rename from src/libs/actions/Welcome.ts rename to src/libs/actions/Welcome/index.ts index d54314ae6f05..f5995aa1e2a9 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome/index.ts @@ -10,6 +10,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type Onboarding from '@src/types/onyx/Onboarding'; import type TryNewDot from '@src/types/onyx/TryNewDot'; +import * as OnboardingFlow from './OnboardingFlow'; type OnboardingData = Onboarding | [] | undefined; @@ -46,6 +47,7 @@ function onServerDataReady(): Promise { return isServerDataReadyPromise; } +let isOnboardingInProgress = false; function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOnboardingFlowProps) { isOnboardingFlowStatusKnownPromise.then(() => { if (Array.isArray(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) { @@ -53,8 +55,10 @@ function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOn } if (onboarding?.hasCompletedGuidedSetupFlow) { + isOnboardingInProgress = false; onCompleted?.(); - } else { + } else if (!isOnboardingInProgress) { + isOnboardingInProgress = true; onNotCompleted?.(); } }); @@ -97,7 +101,7 @@ function handleHybridAppOnboarding() { isOnboardingFlowCompleted({ onNotCompleted: () => setTimeout(() => { - Navigation.navigate(ROUTES.ONBOARDING_ROOT.route); + OnboardingFlow.startOnboardingFlow(); }, variables.explanationModalDelay), }), }); @@ -152,6 +156,10 @@ function setOnboardingPolicyID(policyID?: string) { Onyx.set(ONYXKEYS.ONBOARDING_POLICY_ID, policyID ?? null); } +function updateOnboardingLastVisitedPath(path: string) { + Onyx.merge(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, path); +} + function completeHybridAppOnboarding() { const optimisticData: OnyxUpdate[] = [ { @@ -213,12 +221,15 @@ function resetAllChecks() { resolveOnboardingFlowStatus = resolve; }); isLoadingReportData = true; + isOnboardingInProgress = false; + OnboardingFlow.clearInitialPath(); } export { onServerDataReady, isOnboardingFlowCompleted, setOnboardingPurposeSelected, + updateOnboardingLastVisitedPath, resetAllChecks, setOnboardingAdminsChatReportID, setOnboardingPolicyID, diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index d2dbd7eff953..49052208bfdc 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,13 +14,13 @@ 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'; import * as PersonalDetails from '@userActions/PersonalDetails'; import * as Report from '@userActions/Report'; import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/DisplayNameForm'; @@ -40,7 +39,6 @@ function BaseOnboardingPersonalDetails({ const {shouldUseNarrowLayout, onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); const {inputCallbackRef} = useAutoFocusInput(); const [shouldValidateOnChange, setShouldValidateOnChange] = useState(false); - const {accountID} = useSession(); useEffect(() => { Welcome.setOnboardingErrorMessage(''); @@ -76,14 +74,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'>) => { @@ -131,7 +125,7 @@ function BaseOnboardingPersonalDetails({ { + const menuItems: MenuItemProps[] = Object.values(CONST.SELECTABLE_ONBOARDING_CHOICES).map((choice) => { const translationKey = `onboarding.purpose.${choice}` as const; return { key: translationKey, diff --git a/src/pages/OnboardingWork/BaseOnboardingWork.tsx b/src/pages/OnboardingWork/BaseOnboardingWork.tsx index 0ee6fec55aa4..f803b4e34a65 100644 --- a/src/pages/OnboardingWork/BaseOnboardingWork.tsx +++ b/src/pages/OnboardingWork/BaseOnboardingWork.tsx @@ -18,14 +18,13 @@ import Navigation from '@libs/Navigation/Navigation'; import * as ValidationUtils from '@libs/ValidationUtils'; import * as Policy from '@userActions/Policy/Policy'; import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/WorkForm'; import type {BaseOnboardingWorkOnyxProps, BaseOnboardingWorkProps} from './types'; -const OPEN_WORK_PAGE_PURPOSES = [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]; - function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected, onboardingPolicyID, route}: BaseOnboardingWorkProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -79,8 +78,8 @@ function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected, o diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index de93ed7a3ced..aa419faedc29 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -7,6 +7,7 @@ import {useOnyx, withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import DelegateNoAccessModal from '@components/DelegateNoAccessModal'; import DisplayNames from '@components/DisplayNames'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -22,6 +23,7 @@ import RoomHeaderAvatars from '@components/RoomHeaderAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useDelegateUserDetails from '@hooks/useDelegateUserDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; @@ -234,15 +236,19 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD const [moneyRequestReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${moneyRequestReport?.reportID}`); const isMoneyRequestExported = ReportUtils.isExported(moneyRequestReportActions); + const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); + const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const unapproveExpenseReportOrShowModal = useCallback(() => { - if (isMoneyRequestExported) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (isMoneyRequestExported) { setIsUnapproveModalVisible(true); return; } Navigation.dismissModal(); IOU.unapproveExpenseReport(moneyRequestReport); - }, [isMoneyRequestExported, moneyRequestReport]); + }, [isMoneyRequestExported, moneyRequestReport, isDelegateAccessRestricted]); const shouldShowLeaveButton = ReportUtils.canLeaveChat(report, policy); @@ -552,6 +558,8 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD isTextHold: canHoldUnholdReportAction.canHoldRequest, reportAction: moneyRequestAction, reportID: transactionThreadReportID ? report.reportID : moneyRequestAction?.childReportID ?? '-1', + isDelegateAccessRestricted, + setIsNoDelegateAccessMenuVisible, }), ); } @@ -563,7 +571,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD result.push(PromotedActions.share(report)); return result; - }, [report, moneyRequestAction, canJoin, isExpenseReport, shouldShowHoldAction, canHoldUnholdReportAction.canHoldRequest, transactionThreadReportID]); + }, [report, moneyRequestAction, canJoin, isExpenseReport, shouldShowHoldAction, canHoldUnholdReportAction.canHoldRequest, transactionThreadReportID, isDelegateAccessRestricted]); const nameSectionExpenseIOU = ( @@ -813,6 +821,11 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD danger shouldEnableNewFocusManagement /> + setIsNoDelegateAccessMenuVisible(false)} + delegatorEmail={delegatorEmail ?? ''} + /> Navigation.navigate(ROUTES.TRAVEL_MY_TRIPS), }; + case CONST.SEARCH.DATA_TYPES.CHAT: case CONST.SEARCH.DATA_TYPES.EXPENSE: case CONST.SEARCH.DATA_TYPES.INVOICE: default: diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 810e7140060f..a83fd364fa4a 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -42,6 +42,12 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { icon: Expensicons.Receipt, route: ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: SearchUtils.buildCannedSearchQuery()}), }, + { + title: translate('common.chats'), + type: CONST.SEARCH.DATA_TYPES.CHAT, + icon: Expensicons.ChatBubbles, + route: ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.TRIP.ALL)}), + }, { title: translate('workspace.common.invoices'), type: CONST.SEARCH.DATA_TYPES.INVOICE, diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 56b9cf970d54..9e8c46692bbd 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -121,7 +121,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {allowStaleData: true, initialValue: {}}); const [betas] = useOnyx(ONYXKEYS.BETAS); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportOnyx?.parentReportID || 0}`, { + const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportOnyx?.parentReportID || -1}`, { canEvict: false, selector: (parentReportActions) => getParentReportAction(parentReportActions, reportOnyx?.parentReportActionID ?? ''), }); @@ -132,7 +132,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(parentReportAction); const prevIsDeletedParentAction = usePrevious(isDeletedParentAction); - const isLoadingReportOnyx = isLoadingOnyxValue(reportResult); + const isLoadingReportOnyx = !reportOnyx || Object.keys(reportOnyx).length === 0 || isLoadingOnyxValue(reportResult); const permissions = useDeepCompareRef(reportOnyx?.permissions); useEffect(() => { diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index ff7fb4ff9238..0a0f87f9f592 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -188,7 +188,7 @@ function ReportActionItem({ }); const theme = useTheme(); const styles = useThemeStyles(); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID || -1}`); const StyleUtils = useStyleUtils(); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const [isContextMenuActive, setIsContextMenuActive] = useState(() => ReportActionContextMenu.isActiveReportAction(action.reportActionID)); @@ -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/report/ReportAttachments.tsx b/src/pages/home/report/ReportAttachments.tsx index 7140cd2d45c4..369d5cef6ee4 100644 --- a/src/pages/home/report/ReportAttachments.tsx +++ b/src/pages/home/report/ReportAttachments.tsx @@ -6,6 +6,7 @@ import type {Attachment} from '@components/Attachments/types'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -55,7 +56,8 @@ function ReportAttachments({route}: ReportAttachmentsProps) { ComposerFocusManager.setReadyToFocus(); }} onCarouselAttachmentChange={onCarouselAttachmentChange} - shouldShowNotFoundPage={!isLoadingApp && !report?.reportID} + shouldShowNotFoundPage={!isLoadingApp && type !== CONST.ATTACHMENT_TYPE.SEARCH && !report?.reportID} + isAuthTokenRequired={type === CONST.ATTACHMENT_TYPE.SEARCH} /> ); } 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/request/step/IOURequestStepCategory.tsx b/src/pages/iou/request/step/IOURequestStepCategory.tsx index e94ea724d1ed..f3388fcc7b0e 100644 --- a/src/pages/iou/request/step/IOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/IOURequestStepCategory.tsx @@ -16,6 +16,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; @@ -176,23 +177,25 @@ function IOURequestStepCategory({ subtitle={translate('workspace.categories.emptyCategories.subtitle')} containerStyle={[styles.flex1, styles.justifyContentCenter]} /> - -