diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 69e225bb3b15..90a2fd441272 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -106,6 +106,11 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c - [ ] If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles: - [ ] I verified that all the inputs inside a form are aligned with each other. - [ ] I added `Design` label and/or tagged `@Expensify/design` so the design team can review the changes. +- [ ] If the PR adds or modifies the UI: + - [ ] I asked an AI agent to review the changes for accessibility issues and addressed its findings. + - [ ] I tested with a screen reader (VoiceOver on macOS) and verified all new/changed elements are reachable with a logical focus order. + - [ ] I verified all new/changed elements have meaningful accessible names and roles. + - [ ] I verified state changes are announced (e.g. checked/unchecked, expanded/collapsed, selected). - [ ] I added [unit tests](https://github.com/Expensify/App/blob/main/tests/README.md) for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow. - [ ] If the `main` branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the `Test` steps. diff --git a/.github/actions/composite/uploadSentrySizeAnalysis/action.yml b/.github/actions/composite/uploadSentrySizeAnalysis/action.yml index 153279494c81..32dd91ca5c58 100644 --- a/.github/actions/composite/uploadSentrySizeAnalysis/action.yml +++ b/.github/actions/composite/uploadSentrySizeAnalysis/action.yml @@ -61,9 +61,17 @@ runs: ASSET: ${{ inputs.asset-name }} PLATFORM: ${{ inputs.platform }} run: | + # Capture the exit code so bash -e does not discard the CLI diagnostic on failure + set +e OUTPUT=$(npx sentry-cli build upload "$ASSET" \ --org expensify --project app --build-configuration Release --log-level info 2>&1) + EXIT_CODE=$? + set -e echo "$OUTPUT" + if [ "$EXIT_CODE" -ne 0 ]; then + echo "::error::${PLATFORM} Sentry size-analysis upload failed (exit $EXIT_CODE)" + exit "$EXIT_CODE" + fi SENTRY_URL=$(echo "$OUTPUT" | grep -oE 'https://expensify\.sentry\.io/[^ )"}]+' | head -1) if [ -n "$SENTRY_URL" ]; then echo "::notice::${PLATFORM} Sentry size analysis: $SENTRY_URL" diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index ba528f117579..62a50ec22616 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -26670,6 +26670,12 @@ function getDeployTableMessage(platformResult) { return `${platformResult} \u274C`; } } +function parseDeployList(input) { + if (!Array.isArray(input) || input.some((item) => typeof item !== "string" && typeof item !== "number")) { + throw new TypeError("Deploy pull request list must be an array of strings or numbers"); + } + return input.map((item) => Number.parseInt(String(item), 10)); +} async function commentPR(PR, message, repo = context2.repo.repo) { try { await GithubUtils_default.createComment(repo, PR, message); @@ -26716,7 +26722,10 @@ async function commentOnDeployChecklistPRs(prList, repoName, recentTags, getDepl const deployMessage = deployer ? getDeployMessage(deployer, isCP ? "Cherry-picked" : "Deployed", title) : ""; await commentPR(prNumber, deployMessage, repoName); } catch (error2) { - if (error2.status === 404) { + if (error2 === null || error2 === void 0) { + throw new TypeError(`Cannot read properties of ${String(error2)} (reading 'status')`); + } + if ((typeof error2 === "object" || typeof error2 === "function") && "status" in error2 && error2.status === 404) { console.log(`Unable to comment on ${repoName} PR #${prNumber}. GitHub responded with 404.`); } else if (repoName === CONST_default.MOBILE_EXPENSIFY_REPO && process.env.GITHUB_REPOSITORY !== `${CONST_default.GITHUB_OWNER}/${CONST_default.APP_REPO}`) { console.warn(`Unable to comment on ${repoName} PR #${prNumber} from forked repository. This is expected.`); @@ -26729,10 +26738,10 @@ async function commentOnDeployChecklistPRs(prList, repoName, recentTags, getDepl await Promise.all(commentPromises); } async function run() { - const prList = getJSONInput("PR_LIST", { required: true }).map((num) => Number.parseInt(num, 10)); + const prList = parseDeployList(getJSONInput("PR_LIST", { required: true })); const mobileExpensifyPRListInput = getJSONInput("MOBILE_EXPENSIFY_PR_LIST", { required: false }); - const mobileExpensifyPRList = Array.isArray(mobileExpensifyPRListInput) ? mobileExpensifyPRListInput.map((num) => Number.parseInt(num, 10)) : []; - const isProd = getJSONInput("IS_PRODUCTION_DEPLOY", { + const mobileExpensifyPRList = Array.isArray(mobileExpensifyPRListInput) ? parseDeployList(mobileExpensifyPRListInput) : []; + const isProd = !!getJSONInput("IS_PRODUCTION_DEPLOY", { required: true }); const version = getInput("DEPLOY_VERSION", { required: true }); diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts b/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts index addf9aa3b931..0ea99f581d39 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts +++ b/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts @@ -5,18 +5,14 @@ import GithubUtils from '@github/libs/GithubUtils'; import PromisePool from '@scripts/utils/PromisePool'; -import type {RequestError} from '@octokit/types'; - import * as core from '@actions/core'; import {context} from '@actions/github'; import memoize from 'lodash/memoize'; -type PlatformResult = 'success' | 'cancelled' | 'skipped' | 'failure'; - /** * Return a nicely formatted message for the table based on the result of the GitHub action job */ -function getDeployTableMessage(platformResult: PlatformResult): string { +function getDeployTableMessage(platformResult: string): string { switch (platformResult) { case 'success': return `${platformResult} ✅`; @@ -30,6 +26,14 @@ function getDeployTableMessage(platformResult: PlatformResult): string { } } +function parseDeployList(input: unknown): number[] { + if (!Array.isArray(input) || input.some((item) => typeof item !== 'string' && typeof item !== 'number')) { + throw new TypeError('Deploy pull request list must be an array of strings or numbers'); + } + + return input.map((item) => Number.parseInt(String(item), 10)); +} + async function commentPR(PR: number, message: string, repo: string = context.repo.repo) { try { await GithubUtils.createComment(repo, PR, message); @@ -90,7 +94,11 @@ async function commentOnDeployChecklistPRs( const deployMessage = deployer ? getDeployMessage(deployer, isCP ? 'Cherry-picked' : 'Deployed', title) : ''; await commentPR(prNumber, deployMessage, repoName); } catch (error) { - if ((error as RequestError).status === 404) { + // Preserve the former direct `.status` access ordering for nullish rejections. + if (error === null || error === undefined) { + throw new TypeError(`Cannot read properties of ${String(error)} (reading 'status')`); + } + if ((typeof error === 'object' || typeof error === 'function') && 'status' in error && error.status === 404) { console.log(`Unable to comment on ${repoName} PR #${prNumber}. GitHub responded with 404.`); } else if (repoName === CONST.MOBILE_EXPENSIFY_REPO && process.env.GITHUB_REPOSITORY !== `${CONST.GITHUB_OWNER}/${CONST.APP_REPO}`) { console.warn(`Unable to comment on ${repoName} PR #${prNumber} from forked repository. This is expected.`); @@ -105,17 +113,17 @@ async function commentOnDeployChecklistPRs( } async function run() { - const prList = (ActionUtils.getJSONInput('PR_LIST', {required: true}) as string[]).map((num) => Number.parseInt(num, 10)); + const prList = parseDeployList(ActionUtils.getJSONInput('PR_LIST', {required: true})); const mobileExpensifyPRListInput = ActionUtils.getJSONInput('MOBILE_EXPENSIFY_PR_LIST', {required: false}); - const mobileExpensifyPRList = Array.isArray(mobileExpensifyPRListInput) ? mobileExpensifyPRListInput.map((num: string) => Number.parseInt(num, 10)) : []; - const isProd = ActionUtils.getJSONInput('IS_PRODUCTION_DEPLOY', { + const mobileExpensifyPRList = Array.isArray(mobileExpensifyPRListInput) ? parseDeployList(mobileExpensifyPRListInput) : []; + const isProd = !!ActionUtils.getJSONInput('IS_PRODUCTION_DEPLOY', { required: true, - }) as boolean; + }); const version = core.getInput('DEPLOY_VERSION', {required: true}); - const androidResult = getDeployTableMessage(core.getInput('ANDROID', {required: true}) as PlatformResult); - const iOSResult = getDeployTableMessage(core.getInput('IOS', {required: true}) as PlatformResult); - const webResult = getDeployTableMessage(core.getInput('WEB', {required: true}) as PlatformResult); + const androidResult = getDeployTableMessage(core.getInput('ANDROID', {required: true})); + const iOSResult = getDeployTableMessage(core.getInput('IOS', {required: true})); + const webResult = getDeployTableMessage(core.getInput('WEB', {required: true})); const date = core.getInput('DATE'); const note = core.getInput('NOTE'); diff --git a/.github/scripts/syncVersions.sh b/.github/scripts/syncVersions.sh index 6a961183fa6a..eed20edf5d22 100755 --- a/.github/scripts/syncVersions.sh +++ b/.github/scripts/syncVersions.sh @@ -17,16 +17,17 @@ # with push access to App main. # - GITHUB_OUTPUT: written by `check` and `sync` (defaults to /dev/null outside GitHub Actions). # - TARGET_VERSION (sync, optional): version to sync to. Must equal the Mobile-Expensify version, -# since only the App side is rewritten and the final verification compares the two. Empty means -# "use the Mobile-Expensify version". +# since only the App side is rewritten and the final verification compares the two. Anything else +# is rejected before the sync writes or pushes. Empty means "use the Mobile-Expensify version". # - NEED_FULL_VERSION_SYNC (sync): 'true' to rewrite versions, anything else to only bump the # submodule pointer. Threaded from `check` so exactly one decision exists across both steps. # - EXPECTED_SUBMODULE_SHA (sync): Mobile-Expensify SHA `check` updated to. The submodule-only -# path asserts against it so a checkout that moved in between is caught. +# path asserts against it so a checkout that moved in between is caught, and verification uses +# it to tell "this sync went wrong" apart from "Mobile-Expensify moved on again". # # Outputs (via GITHUB_OUTPUT): # - check: IN_SYNC, NEED_FULL_VERSION_SYNC, ACTUAL_SHA -# - sync: POST_SYNC_APP_VERSION +# - sync: POST_SYNC_APP_VERSION, SUBMODULE_MATCHES_REMOTE # # Side effects: # - `check` is not read-only: it runs `git submodule update --remote`. @@ -96,17 +97,25 @@ function update_ios_versions { } function resolve_target_version { - if [[ -n "$TARGET_VERSION" ]]; then - echo "Using provided target version: $TARGET_VERSION" >&2 - echo "$TARGET_VERSION" - return - fi - # Use Mobile-Expensify version as source of truth since it was pushed first local me_version me_version="$(get_mobile_expensify_version)" - echo "Using Mobile-Expensify version as target: $me_version" >&2 - echo "$me_version" + + if [[ -z "$TARGET_VERSION" ]]; then + echo "Using Mobile-Expensify version as target: $me_version" >&2 + echo "$me_version" + return + fi + + # Only the App side is rewritten, so any other target fails verification -- after the commits + # have already been pushed to main. Reject it before anything is written. + if [[ "$TARGET_VERSION" != "$me_version" ]]; then + echo "::error::TARGET_VERSION ($TARGET_VERSION) must match the Mobile-Expensify version ($me_version)" >&2 + exit 1 + fi + + echo "Using provided target version: $TARGET_VERSION" >&2 + echo "$TARGET_VERSION" } function cmd_check { @@ -168,10 +177,12 @@ function sync_full_version { git add package.json package-lock.json android/app/build.gradle ios/*/Info.plist git commit -m "Update version to $target (sync recovery)" - # Stages the submodule's checked-out HEAD, which is the commit `check` moved it to with - # --remote, not whatever main currently records. + # Stages the submodule's checked-out HEAD, the commit `check` moved it to with --remote. That can + # already be what main records, when a previous sync got this far and then died, hence the guard. git add Mobile-Expensify - git commit -m "Update Mobile-Expensify submodule version to $target (sync recovery)" + if ! git diff --staged --quiet; then + git commit -m "Update Mobile-Expensify submodule version to $target (sync recovery)" + fi # main is busy enough that another merge can land between `check` and here, which makes this # push non fast-forward. @@ -233,12 +244,25 @@ function verify_sync { recorded=$(git rev-parse HEAD:Mobile-Expensify) remote_sha=$(git -C Mobile-Expensify rev-parse origin/main) if [[ "$recorded" != "$remote_sha" ]]; then - echo "::error::Submodule on App main ($recorded) still differs from Mobile-Expensify origin/main ($remote_sha)" - exit 1 + # Mobile-Expensify main can advance mid-run, which is likely because a deployer triggers this + # during a deploy. Recording what `check` pinned is still a successful sync, so report it and + # let the next run collect the newer commit. + if [[ "$recorded" == "$EXPECTED_SUBMODULE_SHA" ]]; then + echo "::warning::Mobile-Expensify main advanced to $remote_sha while syncing. App main records $recorded, which is what this run set out to record. Re-run to pick up the newer commit." + set_output SUBMODULE_MATCHES_REMOTE false + else + echo "::error::Submodule on App main ($recorded) still differs from Mobile-Expensify origin/main ($remote_sha)" + exit 1 + fi fi set_output POST_SYNC_APP_VERSION "$app_version" - echo "::notice::✅ Verified versions ($app_version) and submodule match Mobile-Expensify main ($recorded)" + if [[ "$recorded" == "$remote_sha" ]]; then + set_output SUBMODULE_MATCHES_REMOTE true + echo "::notice::✅ Verified versions ($app_version) and submodule match Mobile-Expensify main ($recorded)" + else + echo "::notice::✅ Verified versions ($app_version); submodule records $recorded, one behind Mobile-Expensify main" + fi } function cmd_sync { diff --git a/.github/workflows/buildAdHoc.yml b/.github/workflows/buildAdHoc.yml index 694b9927898a..3633c87fb9cb 100644 --- a/.github/workflows/buildAdHoc.yml +++ b/.github/workflows/buildAdHoc.yml @@ -136,6 +136,7 @@ jobs: BUCKET="s3://ad-hoc-expensify-cash/web/${{ inputs.APP_PR_NUMBER }}" aws s3 sync dist "$BUCKET" --delete --acl public-read \ + --exclude "*.br" \ --exclude "index.html" \ --exclude "service-worker.js" \ --exclude "workbox-*.js" \ diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 98324697a22a..0b09c8f85a77 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -3,23 +3,22 @@ name: PR Reviews with Claude Code permissions: contents: read pull-requests: write + statuses: write on: pull_request_target: types: [opened, ready_for_review] - -concurrency: - group: claude-review-${{ github.event.pull_request.html_url }} - cancel-in-progress: true + issue_comment: + types: [created] jobs: - review: - if: | - github.event.pull_request.draft != true - && !contains(github.event.pull_request.title, 'Revert') + authorize: + if: >- + (github.event_name == 'pull_request_target' && github.event.pull_request.draft != true && !contains(github.event.pull_request.title, 'Revert')) || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '@claude review')) runs-on: blacksmith-2vcpu-ubuntu-2404 - env: - PR_NUMBER: ${{ github.event.pull_request.number }} + outputs: + IS_AUTHORIZED: ${{ steps.gate.outputs.IS_AUTHORIZED }} steps: - name: Checkout uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 @@ -28,98 +27,113 @@ jobs: id: gate uses: ./.github/actions/javascript/isAuthorizedContributor with: - PR_NUMBER: ${{ github.event.pull_request.number }} - ACTOR: ${{ github.event.pull_request.user.login }} - ACTOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + ACTOR: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.user.login || github.event.comment.user.login }} + ACTOR_ASSOCIATION: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.author_association || github.event.comment.author_association }} GITHUB_TOKEN: ${{ github.token }} OS_BOTIFY_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - - name: Set review authorized - id: set-authorized - run: | - if [ "${{ steps.gate.outputs.IS_AUTHORIZED }}" = "true" ]; then - echo "IS_AUTHORIZED=true" >> "$GITHUB_OUTPUT" - else - echo "IS_AUTHORIZED=false" >> "$GITHUB_OUTPUT" - fi - + review: + needs: authorize + if: needs.authorize.outputs.IS_AUTHORIZED == 'true' + # Set on this job, after authorization, so an unauthorized "@claude review" comment cannot cancel a running review + concurrency: + group: claude-review-${{ github.event.pull_request.html_url || github.event.issue.html_url }} + cancel-in-progress: true + runs-on: blacksmith-2vcpu-ubuntu-2404 + env: + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + steps: - name: Checkout repository - if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 with: fetch-depth: 1 - - name: Setup Node - if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' - uses: ./.github/actions/composite/setupNode - - - name: Filter paths - if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 - id: filter - with: - filters: | - code: - - 'src/**' - docs: - - 'docs/**/*.md' - - 'docs/**/*.csv' - - name: Setup Claude review toolkit - if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' id: toolkit uses: Expensify/GitHub-Actions/.github/actions/claude-review-toolkit@54bfb8923d4f94c3cb209bfbad362fca9f0816f5 with: rules_directory: .claude/skills/app-coding-standards/rules + - name: Check for an existing review of this commit + id: skip + env: + GH_TOKEN: ${{ github.token }} + run: shouldSkipReview.sh "$PR_NUMBER" "ai-review-completed/claude" + + - name: Filter paths + if: steps.skip.outputs.skip != 'true' + id: filter + env: + GH_TOKEN: ${{ github.token }} + run: | + # gh pr view --json files is a GraphQL query capped at 100 files with no pagination, + # so a large PR would silently lose the paths that decide whether a review runs. + gh api --paginate "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' > "$RUNNER_TEMP/changed-files.txt" + if grep -q '^src/' "$RUNNER_TEMP/changed-files.txt"; then + echo "code=true" >> "$GITHUB_OUTPUT" + fi + if grep -qE '^docs/.*\.(md|csv)$' "$RUNNER_TEMP/changed-files.txt"; then + echo "docs=true" >> "$GITHUB_OUTPUT" + fi - name: Mark review as in progress - if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' + if: steps.skip.outputs.skip != 'true' env: GH_TOKEN: ${{ github.token }} run: | addPrReaction.sh "$PR_NUMBER" "eyes" + - name: Setup Node + if: steps.skip.outputs.skip != 'true' && (steps.filter.outputs.code == 'true' || steps.filter.outputs.docs == 'true') + uses: ./.github/actions/composite/setupNode + - name: Run Claude Code (code) id: code-review - if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' && steps.filter.outputs.code == 'true' + if: steps.skip.outputs.skip != 'true' && steps.filter.outputs.code == 'true' uses: anthropics/claude-code-action@4d7e1f0cd85743fdc93b1c8040ab54395da024e2 # v1.0.149 with: display_report: 'true' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} allowed_non_write_users: '*' - prompt: '/review-code-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}' + prompt: '/review-code-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}' claude_args: | --model claude-opus-4-8 --effort xhigh --allowedTools "Task,Glob,Grep,Read,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(check-compiler.sh:*)" --json-schema '${{ steps.toolkit.outputs.schema_json }}' - name: Post code review results - if: | - steps.set-authorized.outputs.IS_AUTHORIZED == 'true' - && steps.code-review.outcome == 'success' - && steps.filter.outputs.code == 'true' + if: steps.code-review.outcome == 'success' env: GH_TOKEN: ${{ github.token }} STRUCTURED_OUTPUT: ${{ steps.code-review.outputs.structured_output }} run: postCodeReviewResults.sh "$PR_NUMBER" - name: Run Claude Code (docs) - if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' && steps.filter.outputs.docs == 'true' + id: docs-review + if: steps.skip.outputs.skip != 'true' && steps.filter.outputs.docs == 'true' uses: anthropics/claude-code-action@4d7e1f0cd85743fdc93b1c8040ab54395da024e2 # v1.0.149 with: display_report: 'true' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} allowed_non_write_users: '*' - prompt: '/review-helpdot-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}' + prompt: '/review-helpdot-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}' claude_args: | --model claude-opus-4-8 --effort high --allowedTools "Task,Glob,Grep,Read,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),mcp__github_inline_comment__create_inline_comment" + - name: Record review completion + if: steps.code-review.outcome == 'success' || steps.docs-review.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ steps.skip.outputs.head_sha }} + STATUS_CONTEXT: ${{ steps.skip.outputs.context }} + run: recordReviewComplete.sh "$HEAD_SHA" "$STATUS_CONTEXT" "Reviewed at this commit - comment @claude review to re-run" + - name: Remove in-progress indicator - if: always() && steps.set-authorized.outputs.IS_AUTHORIZED == 'true' + if: always() && steps.skip.outputs.skip != 'true' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a1e6219d0b9e..0621b4cda065 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -741,7 +741,16 @@ jobs: - name: Deploy to S3 run: | - aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist ${{ env.S3_BUCKET }}/ + aws s3 cp --recursive --acl public-read --exclude "*.br" "$GITHUB_WORKSPACE"/dist ${{ env.S3_BUCKET }}/ + + for PAIR in js:text/javascript css:text/css html:text/html svg:image/svg+xml wasm:application/wasm ttf:font/ttf; do + EXT="${PAIR%%:*}" + CONTENT_TYPE="${PAIR#*:}" + aws s3 cp --recursive --acl public-read --exclude "*" --include "*.${EXT}.br" \ + --content-encoding br --content-type "$CONTENT_TYPE" \ + "$GITHUB_WORKSPACE"/dist ${{ env.S3_BUCKET }}/ + done + aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_BUCKET }}/.well-known/apple-app-site-association ${{ env.S3_BUCKET }}/.well-known/apple-app-site-association aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_BUCKET }}/.well-known/apple-app-site-association ${{ env.S3_BUCKET }}/apple-app-site-association env: @@ -1142,18 +1151,19 @@ jobs: iosUploadSentry: name: Upload iOS build to Sentry for size analysis needs: [prep, iosBuild, checkDeploymentSuccess] - runs-on: blacksmith-2vcpu-ubuntu-2404 + # sentry-cli only supports IPA uploads when running on Apple Silicon macOS + runs-on: blacksmith-6vcpu-macos-latest if: >- ${{ always() && needs.prep.outputs.DEPLOY_ENV == 'production' && fromJSON(needs.checkDeploymentSuccess.outputs.IS_RELEASE_READY) }} continue-on-error: true - timeout-minutes: 10 + timeout-minutes: 20 outputs: SENTRY_URL: ${{ steps.sentry-upload.outputs.SENTRY_URL }} steps: - name: Checkout - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Upload to Sentry for size analysis id: sentry-upload diff --git a/.github/workflows/syncVersions.yml b/.github/workflows/syncVersions.yml index 4bac587a75dd..fa2800575792 100644 --- a/.github/workflows/syncVersions.yml +++ b/.github/workflows/syncVersions.yml @@ -89,7 +89,7 @@ jobs: "channel": "#deployer", "attachments": [{ "color": "good", - "text": "✅ Version sync completed. E/App and Mobile-Expensify are at ${{ steps.performSync.outputs.POST_SYNC_APP_VERSION }}; submodule matches Mobile-Expensify main." + "text": "✅ Version sync completed. E/App and Mobile-Expensify are at ${{ steps.performSync.outputs.POST_SYNC_APP_VERSION }}; ${{ steps.performSync.outputs.SUBMODULE_MATCHES_REMOTE == 'true' && 'submodule matches Mobile-Expensify main.' || 'Mobile-Expensify main has advanced since, so run this again to record it.' }}" }] } diff --git a/CLAUDE.md b/CLAUDE.md index 57e70fe5024d..b83dc7430e44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Use the `/react-native-best-practices` skill when working on performance-sensiti ### Memoization -React Compiler auto-memoizes code in components and hooks (excluding `tests/`). Two different compilers run it: `babel-plugin-react-compiler` on native/Jest (see `babel.config.js`) and `oxc-transform-react` on web (see `config/rsbuild/`). They do not behave identically. The compliance check and the ESLint processor run BOTH compilers via the shared helpers in `config/reactCompiler/` and only relax manual-memoization rules when both compilers memoize the file. +React Compiler auto-memoizes code in components and hooks (excluding `tests/`). Two different compilers run it: `babel-plugin-react-compiler` on native (see `babel.config.js`) and `oxc-transform-react` on web (see `config/rsbuild/`) and under Jest (see `config/babel/oxcJestTransformer.js`). They do not behave identically. The compliance check and the ESLint processor run BOTH compilers via the shared helpers in `config/reactCompiler/` and only relax manual-memoization rules when both compilers memoize the file. Do not use `useMemo`, `useCallback`, or `React.memo` in components or hooks that compile with React Compiler. diff --git a/Gemfile.lock b/Gemfile.lock index 8a1ddf25dcff..cf527b83283e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,7 +15,7 @@ GEM minitest (>= 5.1, < 6) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) - addressable (2.8.8) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) @@ -103,7 +103,7 @@ GEM ethon (0.15.0) ffi (>= 1.15.0) excon (0.112.0) - faraday (1.10.4) + faraday (1.10.6) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -242,7 +242,7 @@ GEM concurrent-ruby (~> 1.0) jmespath (1.6.2) json (2.18.0) - jwt (2.10.2) + jwt (2.10.3) base64 logger (1.7.0) mime-types (3.7.0) diff --git a/Mobile-Expensify b/Mobile-Expensify index be6570d405b4..1667f64712fe 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit be6570d405b47095ebc0f1895241dfb470fc4328 +Subproject commit 1667f64712fe146f87ece9f5acb70dee1c21f608 diff --git a/README.md b/README.md index 08da77928ec8..5e737b99952d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ * [Expensify Code of Conduct](CODE_OF_CONDUCT.md) * [Contributor License Agreement](CLA.md) * [React StrictMode](contributingGuides/STRICT_MODE.md) -* [Left Hand Navigation(LHN)](contributingGuides/LEFT_HAND_NAVIGATION.md) +* [Left Hand Navigation (LHN)](contributingGuides/LEFT_HAND_NAVIGATION.md) * [HybridApp - additional info & troubleshooting](contributingGuides/HYBRID_APP.md) ---- diff --git a/__mocks__/@ua/react-native-airship.ts b/__mocks__/@ua/react-native-airship.ts index 0f5c34559dda..3c484269589a 100644 --- a/__mocks__/@ua/react-native-airship.ts +++ b/__mocks__/@ua/react-native-airship.ts @@ -1,4 +1,16 @@ -import type {AirshipContact, AirshipLiveActivityManager, AirshipPush, AirshipPushAndroid, AirshipPushIOS, AirshipRoot, AirshipRootIOS} from '@ua/react-native-airship'; +import type { + AirshipContact, + AirshipLiveActivityManager, + AirshipPush, + AirshipPushAndroid, + AirshipPushIOS, + AirshipRoot, + AirshipRootIOS, + LiveActivity, + PushNotificationStatus, +} from '@ua/react-native-airship'; + +import createMock from 'tests/utils/createMock'; // eslint-disable-next-line no-restricted-syntax enum EventType { @@ -43,43 +55,51 @@ namespace iOS { } } -const pushIOS = jest.fn().mockImplementation(() => ({ +const pushIOS = createMock({ setBadgeNumber: jest.fn(), setForegroundPresentationOptions: jest.fn(), setForegroundPresentationOptionsCallback: jest.fn(), -}))() as AirshipPushIOS; +}); -const pushAndroid = jest.fn().mockImplementation(() => ({ +const pushAndroid = createMock({ setForegroundDisplayPredicate: jest.fn(), -}))() as AirshipPushAndroid; +}); -const push = jest.fn().mockImplementation(() => ({ +const notificationStatus: PushNotificationStatus = { + isUserNotificationsEnabled: false, + areNotificationsAllowed: false, + isPushPrivacyFeatureEnabled: false, + isPushTokenRegistered: false, + isOptedIn: false, + isUserOptedIn: false, + notificationPermissionStatus: PermissionStatus.Denied, +}; +const push = createMock({ iOS: pushIOS, android: pushAndroid, enableUserNotifications: () => Promise.resolve(false), clearNotifications: jest.fn(), - getNotificationStatus: () => Promise.resolve({airshipOptIn: false, systemEnabled: false, airshipEnabled: false}), + getNotificationStatus: () => Promise.resolve(notificationStatus), getActiveNotifications: () => Promise.resolve([]), -}))() as AirshipPush; +}); -const contact = jest.fn().mockImplementation(() => ({ +const contact = createMock({ identify: jest.fn(), getNamedUserId: () => Promise.resolve(undefined), reset: jest.fn(), - module: jest.fn(), -}))() as AirshipContact; +}); -const liveActivityManager = jest.fn().mockImplementation(() => ({ +const liveActivityManager = createMock({ list: jest.fn(() => Promise.resolve([])), listAll: jest.fn(() => Promise.resolve([])), - start: jest.fn(() => Promise.resolve({id: 'mock-activity-id'})), + start: jest.fn(() => Promise.resolve(createMock({id: 'mock-activity-id'}))), update: jest.fn(() => Promise.resolve()), end: jest.fn(() => Promise.resolve()), -}))() as AirshipLiveActivityManager; +}); -const airshipIOS = jest.fn().mockImplementation(() => ({ +const airshipIOS = createMock({ liveActivityManager, -}))() as AirshipRootIOS; +}); const Airship: Partial = { addListener: jest.fn(), diff --git a/android/app/build.gradle b/android/app/build.gradle index 9ddc5209beea..0347309794a0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009049500 - versionName "9.4.95-0" + versionCode 1009050204 + versionName "9.5.2-4" // 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/bank-icons/first-citizens-bank.svg b/assets/images/bank-icons/first-citizens-bank.svg new file mode 100644 index 000000000000..14369613d98f --- /dev/null +++ b/assets/images/bank-icons/first-citizens-bank.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/cardicons/first-citizens-bank.svg b/assets/images/cardicons/first-citizens-bank.svg new file mode 100644 index 000000000000..be333ccb6b13 --- /dev/null +++ b/assets/images/cardicons/first-citizens-bank.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/integrationicons/business-central-icon-square.svg b/assets/images/integrationicons/business-central-icon-square.svg deleted file mode 100644 index 8cbd22c4e013..000000000000 --- a/assets/images/integrationicons/business-central-icon-square.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__chartsync.svg b/assets/images/simple-illustrations/simple-illustration__chartsync.svg new file mode 100644 index 000000000000..3f3173f1f64e --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__chartsync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/babel/oxcJestTransformer.js b/config/babel/oxcJestTransformer.js index 8ce531614943..b36f35e90b63 100644 --- a/config/babel/oxcJestTransformer.js +++ b/config/babel/oxcJestTransformer.js @@ -1,19 +1,79 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const esbuild = require('esbuild'); -const {transformSync} = require('oxc-transform-react'); const babelJest = require('babel-jest'); -const OXC_TRANSFORM_REACT_VERSION = require('oxc-transform-react/package.json').version; const oxcReactCompilerConfig = require('./oxcReactCompilerConfig'); const babelTransformer = babelJest.createTransformer(); +let oxcPipeline; +/** + * esbuild and oxc-transform-react are native modules, loaded once per worker. Requiring them lazily + * keeps a run that never takes the OXC path from paying for them at all. + * + * @returns {{esbuild: typeof import('esbuild'), transformSync: Function, oxcVersion: string}} + */ +function getOxcPipeline() { + if (!oxcPipeline) { + oxcPipeline = { + esbuild: require('esbuild'), + transformSync: require('oxc-transform-react').transformSync, + oxcVersion: require('oxc-transform-react/package.json').version, + }; + } + return oxcPipeline; +} + const NODE_MODULES_RE = /[/\\]node_modules[/\\]/; const TESTS_RE = /[/\\]tests[/\\]/; const JEST_SETUP_RE = /[/\\]jest[/\\]/; const MOCKS_RE = /[/\\]__mocks__[/\\]/; +// Test files live under tests/ today, but a colocated one would lose `jest.mock` hoisting silently. +const TEST_FILE_RE = /\.(test|spec)\.[jt]sx?$/; + +// esbuild's export helpers act differently from Babel's: `jest.spyOn` can't override their exports, +// and a circular import throws instead of giving `undefined`. Patch both to act like Babel. Regexes, +// because esbuild renames the helper's variables (`name` becomes `name2`) when the name is taken. +const ESBUILD_HELPERS = [ + { + marker: 'var __defProp', + pattern: /var (__defProp\d*) = Object\.defineProperty;/, + replace: (_match, defProp) => `var ${defProp} = (target, key, descriptor) => Object.defineProperty(target, key, {...descriptor, configurable: true});`, + }, + { + marker: 'var __export', + pattern: /var (__export\d*) = \(target, all\) => \{\s*for \(var (\w+) in all\)\s*(__defProp\d*)\(target, \2, \{ get: all\[\2\], enumerable: true \}\);\s*\};/, + // Keep this to 4 lines, like esbuild's helper: patching runs after the source map is made. + replace: (_match, exportName, _loopVar, defProp) => `var ${exportName} = (target, all) => { + for (const key of Object.keys(all)) + ${defProp}(target, key, {get: () => {try {return all[key]();} catch (e) {if (e instanceof ReferenceError) {return undefined;} throw e;}}, enumerable: true}); +};`, + }, +]; + +/** + * Patch the esbuild helpers above. A missing helper is fine (the module doesn't need it), but one + * we can't match means esbuild changed it and the patch would silently do nothing, so throw. + * + * @param {string} code + * @param {string} sourcePath + * @returns {string} + */ +function patchEsbuildHelpers(code, sourcePath) { + let patched = code; + for (const {marker, pattern, replace} of ESBUILD_HELPERS) { + if (!patched.includes(marker)) { + continue; + } + const next = patched.replace(pattern, replace); + if (next === patched) { + throw new Error(`oxcJestTransformer: esbuild interop helper changed shape, update ESBUILD_HELPERS (while transforming ${sourcePath})`); + } + patched = next; + } + return patched; +} const TRANSFORMER_SOURCE = fs.readFileSync(__filename); const REACT_COMPILER_CONFIG_KEY = JSON.stringify(oxcReactCompilerConfig()); @@ -31,11 +91,19 @@ function getLang(filename) { return 'jsx'; } -function shouldUseOxc(filename) { - return !NODE_MODULES_RE.test(filename) && !TESTS_RE.test(filename) && !JEST_SETUP_RE.test(filename) && !MOCKS_RE.test(filename); +/** + * Coverage runs stay on babel-jest: it adds coverage in the pass it already makes, while the OXC path + * would need a third pass (OXC, esbuild, then istanbul). On CI that was slower and used more memory. + */ +function shouldUseOxc(filename, transformOptions) { + if (transformOptions?.instrument) { + return false; + } + return !NODE_MODULES_RE.test(filename) && !TESTS_RE.test(filename) && !JEST_SETUP_RE.test(filename) && !MOCKS_RE.test(filename) && !TEST_FILE_RE.test(filename); } function processWithOxc(sourceText, sourcePath) { + const {esbuild, transformSync} = getOxcPipeline(); const oxcResult = transformSync(sourcePath, sourceText, { lang: getLang(sourcePath), sourcemap: true, @@ -47,7 +115,9 @@ function processWithOxc(sourceText, sourcePath) { return null; } - const cjs = esbuild.transformSync(oxcResult.code, { + // esbuild chains an inline input map, so its output maps straight back to the original file. + const inputMap = Buffer.from(JSON.stringify(oxcResult.map)).toString('base64'); + const cjs = esbuild.transformSync(`${oxcResult.code}\n//# sourceMappingURL=data:application/json;base64,${inputMap}`, { loader: 'js', format: 'cjs', supported: {'dynamic-import': false}, @@ -55,16 +125,19 @@ function processWithOxc(sourceText, sourcePath) { sourcemap: true, }); - return {code: cjs.code, map: cjs.map}; + return {code: patchEsbuildHelpers(cjs.code, sourcePath), map: cjs.map}; } module.exports = { - canInstrument: false, + // Coverage runs go to babel-jest, which instruments them itself, so Jest must not instrument again. + canInstrument: true, getCacheKey(sourceText, sourcePath, transformOptions) { - if (!shouldUseOxc(sourcePath)) { + if (!shouldUseOxc(sourcePath, transformOptions)) { return babelTransformer.getCacheKey(sourceText, sourcePath, transformOptions); } + const {esbuild, oxcVersion} = getOxcPipeline(); + return crypto .createHash('sha1') .update(sourceText) @@ -73,11 +146,11 @@ module.exports = { .update(TRANSFORMER_SOURCE) .update(REACT_COMPILER_CONFIG_KEY) .update(esbuild.version) - .update(OXC_TRANSFORM_REACT_VERSION) + .update(oxcVersion) .digest('hex'); }, process(sourceText, sourcePath, transformOptions) { - if (shouldUseOxc(sourcePath)) { + if (shouldUseOxc(sourcePath, transformOptions)) { const result = processWithOxc(sourceText, sourcePath); if (result) { return result; diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index b4bc885c092c..10e73863e75b 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -206,6 +206,14 @@ const restrictedPaidGroupPolicyImportPatterns = [ }, ]; +// `HeaderWithBackButton` is being migrated to the composed `Header` (`@components/Header`). +const restrictedHeaderImportPatterns = [ + { + group: ['**/HeaderWithBackButton'], + message: 'HeaderWithBackButton is being migrated to the composed Header. Please use `@components/Header` instead for new usages.', + }, +]; + // Headless email chart CLI cannot use useTheme; charts always render with the light theme. const victoryChartRendererRestrictedImportPaths = restrictedImportPaths.filter((restriction) => restriction.name !== '@styles/theme'); const victoryChartRendererRestrictedImportPatterns = [ @@ -704,7 +712,7 @@ const config = defineConfig([ 'error', { paths: restrictedImportPaths, - patterns: [...restrictedImportPatterns, ...restrictedReportNameImportPatterns, ...restrictedPaidGroupPolicyImportPatterns], + patterns: [...restrictedImportPatterns, ...restrictedReportNameImportPatterns, ...restrictedPaidGroupPolicyImportPatterns, ...restrictedHeaderImportPatterns], }, ], }, diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 6281c209adee..2d3c0bf9e57e 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -5,7 +5,6 @@ "../../__mocks__/@react-native-camera-roll/camera-roll.ts" "@typescript-eslint/no-deprecated/CameraRoll.save" 1 "../../__mocks__/@react-native-community/netinfo.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../__mocks__/@react-navigation/native/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../__mocks__/@ua/react-native-airship.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../__mocks__/expo-video.tsx" "react-hooks/refs" 2 "../../__mocks__/openai.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../__mocks__/react-freeze.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -20,7 +19,6 @@ "../../.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../.github/actions/javascript/getPullRequestDetails/getPullRequestDetails.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../.github/actions/javascript/getPullRequestIncrementalChanges/getPullRequestIncrementalChanges.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 -"../../.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts" "no-restricted-imports" 1 "../../.github/actions/javascript/postOrReplaceComment/postOrReplaceComment.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -63,31 +61,52 @@ "../../src/components/AnimatedSubmitButton/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AnimatedSubmitButton/index.tsx" "react-hooks/refs" 4 "../../src/components/AnimatedSubmitButton/index.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/components/ArchivedReportFooter.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/components/ApproverSelectionList.tsx" "no-restricted-imports" 1 "../../src/components/AttachmentPicker/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AttachmentPicker/index.native.tsx" "react-hooks/refs" 1 "../../src/components/AttachmentPicker/index.tsx" "react-hooks/refs" 1 "../../src/components/AttachmentPreview.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 3 +"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/LegacyComposedGesture" 1 "../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "react-hooks/refs" 3 "../../src/components/Attachments/AttachmentCarousel/index.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/components/Attachments/AttachmentCarousel/Pager/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Native" 2 "../../src/components/Attachments/AttachmentCarousel/Pager/index.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/components/Attachments/AttachmentCarousel/Pager/types.ts" "@typescript-eslint/no-deprecated/GestureType" 2 "../../src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts" "no-restricted-syntax" 1 +"../../src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 "../../src/components/Attachments/AttachmentView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "no-restricted-syntax" 1 "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "react-hooks/refs" 1 "../../src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AutoCompleteSuggestions/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/AutoSubmitModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/AvatarCropModal/AvatarCropView.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/AvatarCropModal/AvatarCropView.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1 +"../../src/components/AvatarCropModal/AvatarCropView.tsx" "no-restricted-imports" 1 +"../../src/components/AvatarCropModal/ImageCropView.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/AvatarCropModal/ImageCropView.tsx" "@typescript-eslint/no-deprecated/LegacyPanGesture" 1 "../../src/components/AvatarCropModal/ImageCropView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/AvatarCropModal/Slider.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/AvatarCropModal/Slider.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1 "../../src/components/AvatarCropModal/Slider.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AvatarSelector.tsx" "no-restricted-syntax" 2 "../../src/components/AvatarWithImagePicker.tsx" "react-hooks/set-state-in-effect" 2 +"../../src/components/BaseVacationDelegateSelectionComponent.tsx" "no-restricted-imports" 1 +"../../src/components/BlockingViews/FullPageNotFoundView.tsx" "no-restricted-imports" 1 "../../src/components/BookTravelButton.tsx" "no-restricted-imports" 1 "../../src/components/ButtonWithDropdownMenu/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/CategoryPicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Charts/hooks/useChartInteractions.ts" "@typescript-eslint/no-deprecated/Gesture.Hover" 2 +"../../src/components/Charts/hooks/useChartInteractions.ts" "@typescript-eslint/no-deprecated/Gesture.Race" 2 +"../../src/components/Charts/hooks/useChartInteractions.ts" "@typescript-eslint/no-deprecated/Gesture.Tap" 2 "../../src/components/Charts/hooks/useChartLabelFormats.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Charts/hooks/useChartLabelMeasurements.ts" "rulesdir/no-raw-typography" 1 +"../../src/components/Charts/PieChart/PieChartContent.tsx" "@typescript-eslint/no-deprecated/Gesture.Hover" 2 +"../../src/components/Charts/PieChart/PieChartContent.tsx" "@typescript-eslint/no-deprecated/Gesture.Race" 2 +"../../src/components/Charts/PieChart/PieChartContent.tsx" "@typescript-eslint/no-deprecated/Gesture.Tap" 2 "../../src/components/Charts/utils/chartFontAssets.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 13 "../../src/components/Charts/utils/chartFontAssets.ts" "@typescript-eslint/no-unsafe-type-assertion" 13 "../../src/components/Charts/utils/chartFontsCache.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -95,19 +114,27 @@ "../../src/components/Charts/utils/getChartSkiaTypeface.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/CheckboxWithLabel.tsx" "no-restricted-syntax" 1 "../../src/components/ColumnsSettingsList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/ColumnsSettingsList.tsx" "no-restricted-imports" 1 "../../src/components/Composer/implementation/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Composer/implementation/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Composer/implementation/index.tsx" "react-hooks/set-state-in-effect" 2 "../../src/components/ConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ConfirmModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 +"../../src/components/ConnectionLayout.tsx" "no-restricted-imports" 1 +"../../src/components/ConnectToMergeFlow/index.ios.tsx" "no-restricted-imports" 1 +"../../src/components/ConnectToMergeFlow/index.native.tsx" "no-restricted-imports" 1 "../../src/components/ConnectToNetSuiteFlow/index.tsx" "react-hooks/refs" 5 "../../src/components/ConnectToNetSuiteFlow/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/ConnectToXeroFlow/index.native.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/ConnectToXeroFlow/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/CountryPicker/CountrySelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/CountryPicker/CountrySelectorModal.tsx" "no-restricted-imports" 1 "../../src/components/CountryPicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/createOnyxContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/CurrencyPicker.tsx" "no-restricted-imports" 1 +"../../src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx" "no-restricted-imports" 1 "../../src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/components/DatePicker/CalendarPicker/YearPickerModal.tsx" "no-restricted-imports" 1 "../../src/components/DatePicker/CalendarPicker/YearPickerModal.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/DatePicker/DatePickerModal.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/DatePicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -120,6 +147,7 @@ "../../src/components/DragAndDrop/Provider/index.tsx" "react-hooks/refs" 6 "../../src/components/DraggableList/SortableItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/DropZone/DropZoneWrapper.tsx" "react-hooks/refs" 1 +"../../src/components/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/EmojiPicker/CategoryShortcutButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/EmojiPicker/EmojiPicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/EmojiPicker/EmojiPicker.tsx" "react-hooks/refs" 3 @@ -132,8 +160,6 @@ "../../src/components/FeedbackSurvey.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/FilePicker/index.native.tsx" "react-hooks/refs" 1 "../../src/components/FilePicker/index.tsx" "react-hooks/refs" 1 -"../../src/components/FlatList/FlatList/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/components/FlatList/FlatList/index.tsx" "react-hooks/refs" 1 "../../src/components/FlatList/hooks/useFlatListHandle.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/FocusTrap/FocusTrapForScreen/index.web.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -142,8 +168,8 @@ "../../src/components/Form/FormProvider.tsx" "react-hooks/refs" 2 "../../src/components/Form/InputWrapper.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Form/InputWrapper.tsx" "react-hooks/refs" 1 -"../../src/components/GPSInProgressModal/index.native.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 -"../../src/components/GPSTripStateChecker/index.native.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 +"../../src/components/GrowlNotification/GrowlNotificationContent.tsx" "@typescript-eslint/no-deprecated/Gesture.Fling" 2 +"../../src/components/HeaderPageLayout.tsx" "no-restricted-imports" 2 "../../src/components/HeaderWithBackButton/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Hoverable/ActiveHoverable.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Hoverable/ActiveHoverable.tsx" "react-hooks/refs" 3 @@ -156,6 +182,7 @@ "../../src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx" "@typescript-eslint/no-deprecated/fontSize" 3 "../../src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/HTMLEngineProvider/HTMLRenderers/HTMLTableScroll/index.native.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 "../../src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -177,8 +204,10 @@ "../../src/components/ImageSVG/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ImportOnyxState/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ImportSpreadsheet.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/components/ImportSpreadsheet.tsx" "no-restricted-imports" 1 "../../src/components/ImportSpreadsheet.tsx" "react-hooks/refs" 1 "../../src/components/InitialURLContextProvider.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/InteractiveStepWrapper.tsx" "no-restricted-imports" 1 "../../src/components/KYCWall/BaseKYCWall.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/KYCWall/BaseKYCWall.tsx" "react-hooks/refs" 2 "../../src/components/LHNOptionsList/LHNEmptyState.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -194,7 +223,10 @@ "../../src/components/Modal/BaseModal.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Modal/Global/ConfirmModalWrapper.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/Modal/Global/ModalContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx" "@typescript-eslint/no-deprecated/GestureType" 1 "../../src/components/Modal/ReanimatedModal/Container/index.web.tsx" "react-hooks/refs" 1 +"../../src/components/MoneyReportHeader.tsx" "no-restricted-imports" 1 "../../src/components/MoneyReportHeaderActions/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/MoneyRequestAmountInput.tsx" "react-hooks/immutability" 2 "../../src/components/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -208,20 +240,33 @@ "../../src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MoneyRequestConfirmationListFooter/sections/DistanceMapSection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MoneyRequestConfirmationListFooter/sections/ReceiptSection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/MoneyRequestHeader.tsx" "no-restricted-imports" 1 "../../src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MultifactorAuthentication/components/Modals/createCancelConfirmModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 +"../../src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx" "no-restricted-imports" 1 "../../src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MultifactorAuthentication/Context/stateReducer.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MultifactorAuthentication/Context/usePromptContent.ts" "react-hooks/refs" 10 "../../src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx" "no-restricted-syntax" 1 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Race" 2 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Simultaneous" 2 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureRef" 1 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 2 "../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MultiGestureCanvas/index.tsx" "no-restricted-syntax" 1 "../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/preserve-manual-memoization" 1 "../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/refs" 2 +"../../src/components/MultiGestureCanvas/usePanGesture.ts" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/MultiGestureCanvas/usePanGesture.ts" "@typescript-eslint/no-deprecated/LegacyPanGesture" 1 "../../src/components/MultiGestureCanvas/usePanGesture.ts" "no-restricted-syntax" 1 +"../../src/components/MultiGestureCanvas/usePinchGesture.ts" "@typescript-eslint/no-deprecated/Gesture.Pinch" 2 +"../../src/components/MultiGestureCanvas/usePinchGesture.ts" "@typescript-eslint/no-deprecated/LegacyPinchGesture" 1 "../../src/components/MultiGestureCanvas/usePinchGesture.ts" "react-hooks/set-state-in-effect" 1 +"../../src/components/MultiGestureCanvas/useTapGestures.ts" "@typescript-eslint/no-deprecated/Gesture.Tap" 4 +"../../src/components/MultiGestureCanvas/useTapGestures.ts" "@typescript-eslint/no-deprecated/LegacyTapGesture" 2 "../../src/components/MultiGestureCanvas/useTapGestures.ts" "no-restricted-syntax" 1 "../../src/components/Navigation/QuickCreationActionsBar/index.tsx" "no-restricted-imports" 1 "../../src/components/NumberWithSymbolForm.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -230,6 +275,7 @@ "../../src/components/OptionRow.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/OptionRow.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/ParentNavigationSubtitle.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/ParticipantPicker/BaseParticipantPicker.tsx" "no-restricted-imports" 1 "../../src/components/PDFView/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PDFView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PDFView/index.tsx" "react-hooks/set-state-in-effect" 1 @@ -245,11 +291,16 @@ "../../src/components/PressableWithSecondaryInteraction/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/PressableWithSecondaryInteraction/index.native.tsx" "no-restricted-syntax" 1 "../../src/components/PressableWithSecondaryInteraction/index.tsx" "no-restricted-syntax" 1 +"../../src/components/PromotedActionsBar.tsx" "no-restricted-imports" 1 +"../../src/components/PushRowWithModal/PushRowModal.tsx" "no-restricted-imports" 1 "../../src/components/RadioButtonWithLabel.tsx" "no-restricted-syntax" 1 "../../src/components/Reactions/AddReactionBubble.tsx" "react-hooks/refs" 1 "../../src/components/Reactions/MiniQuickEmojiReactions.tsx" "react-hooks/refs" 1 +"../../src/components/ReceiptCropView/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 4 +"../../src/components/ReceiptCropView/index.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 2 "../../src/components/ReceiptCropView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/ReimbursementAccountLoadingIndicator.tsx" "no-restricted-imports" 1 "../../src/components/ReportActionAvatars/useReportPreviewSenderID.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ReportActionItem/IssueCardMessage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ReportActionItem/MoneyReportView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -263,17 +314,24 @@ "../../src/components/RNMaskedTextInput.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/RoomHeaderAvatars.tsx" "no-restricted-syntax" 2 "../../src/components/Rule/RuleBooleanBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Rule/RuleBooleanBase.tsx" "no-restricted-imports" 1 +"../../src/components/Rule/RuleSelectionBase.tsx" "no-restricted-imports" 1 +"../../src/components/Rule/RuleTextBase.tsx" "no-restricted-imports" 1 "../../src/components/Rule/TextBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/SAMLLoadingIndicator.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 "../../src/components/ScreenWrapper/ScreenWrapperContainer.tsx" "react-hooks/refs" 2 "../../src/components/Search/chartGroupByConfig.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 +"../../src/components/Search/FilterComponents/AdvancedFilters/DateFilterContent.tsx" "no-restricted-imports" 1 +"../../src/components/Search/FilterComponents/DateFilterBase.tsx" "no-restricted-imports" 1 "../../src/components/Search/FilterComponents/DateFilterBase.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Search/FilterComponents/DatePresetFilterBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Search/FilterComponents/ReportField/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Search/FilterComponents/ReportField/index.tsx" "no-restricted-imports" 1 "../../src/components/Search/FilterComponents/SingleSelect.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/FilterComponents/TypeSelector.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Search/FilterDropdowns/BasePopup.tsx" "no-restricted-imports" 1 +"../../src/components/Search/FilterDropdowns/DateSelectPopup/SelectedDateModifierHeader.tsx" "no-restricted-imports" 1 "../../src/components/Search/FilterDropdowns/SortByPopup.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Search/hooks/useFilterUserValue.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/components/Search/hooks/useOptimisticSearchTracking.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Search/hooks/useStableOptimisticSortedData.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 @@ -281,13 +339,12 @@ "../../src/components/Search/index.tsx" "react-hooks/refs" 5 "../../src/components/Search/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Search/SearchAutocompleteInput.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/components/Search/SearchAutocompleteList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchBulkActionsButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Search/SearchContextDefinitions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchList/ListItem/CardListItemHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Search/SearchList/ListItem/ChatListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -306,16 +363,17 @@ "../../src/components/Search/SearchRouter/getQueryWithSubstitutions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchRouter/SearchRouter.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Search/SearchRouter/SearchRouter.tsx" "no-restricted-imports" 1 "../../src/components/Search/SearchRouter/SearchRouterUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchSingleSelectionPicker.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/SelectionButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/BaseSelectionList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/components/Footer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/hooks/useFlattenedSections.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/components/SelectionList/ListItem/SpendRuleListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/SelectionList/ListItem/SplitListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/SelectionList/ListItem/SplitListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/utils/getListboxRole/index.web.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/SelectionScreen.tsx" "no-restricted-imports" 1 "../../src/components/SettlementButton/AnimatedSettlementButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SettlementButton/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 10 "../../src/components/SettlementButton/index.tsx" "no-restricted-imports" 1 @@ -323,8 +381,14 @@ "../../src/components/SidePanel/SidePanelReport/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SignInButtons/IconButton.tsx" "no-restricted-syntax" 1 "../../src/components/SkeletonViewContentLoader/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx" "no-restricted-imports" 1 +"../../src/components/SpendRules/configuration/SpendRuleMaxAmountBase.tsx" "no-restricted-imports" 1 +"../../src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx" "no-restricted-imports" 1 +"../../src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx" "no-restricted-imports" 1 +"../../src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx" "no-restricted-imports" 1 "../../src/components/StatePicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/StatePicker/StateSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/StatePicker/StateSelectorModal.tsx" "no-restricted-imports" 1 "../../src/components/StatusBadge.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/SubStepForms/AddressStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/SubStepForms/CountryFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -355,11 +419,13 @@ "../../src/components/TextInput/index.tsx" "no-restricted-syntax" 1 "../../src/components/TextInput/TextInputMeasurement/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/TextLinkBlock.tsx" "no-restricted-syntax" 1 +"../../src/components/TextPicker/TextSelectorModal.tsx" "no-restricted-imports" 1 "../../src/components/TextPicker/TextSelectorModal.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/TextWithTooltip/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ThemeStylesContextProvider/default.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ThreeDotsMenu/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ThreeDotsMenu/index.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/components/TimeModalPicker.tsx" "no-restricted-imports" 1 "../../src/components/TimePicker/TimePicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../src/components/TimePicker/TimePicker.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Tooltip/BaseGenericTooltip/index.native.tsx" "react-hooks/refs" 2 @@ -372,16 +438,21 @@ "../../src/components/Tooltip/PopoverAnchorTooltip.tsx" "react-hooks/refs" 7 "../../src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx" "rulesdir/no-raw-typography" 2 "../../src/components/TransactionItemRow/DataCells/MerchantCell.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/UploadFile.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/UserInitialsAvatar.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 +"../../src/components/ValidateCodeInput.tsx" "@typescript-eslint/no-deprecated/Gesture.Tap" 2 "../../src/components/ValidateCodeInput.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ValidateCodeInput.tsx" "react-hooks/refs" 1 "../../src/components/ValidateCodeInput.tsx" "react-hooks/set-state-in-effect" 3 +"../../src/components/ValuePicker/ValueSelectorModal.tsx" "no-restricted-imports" 1 "../../src/components/VideoPlayer/BaseVideoPlayer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/components/VideoPlayer/BaseVideoPlayer.tsx" "react-hooks/refs" 13 "../../src/components/VideoPlayer/BaseVideoPlayer.tsx" "react-hooks/set-state-in-effect" 2 "../../src/components/VideoPlayer/useHandleNativeVideoControls/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1 +"../../src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1 "../../src/components/VideoPlayerContexts/PlaybackContext/playbackContextReportIDUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/WalletStatementModal/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/WideRHPContextProvider/useShouldRenderOverlay.ts" "react-hooks/set-state-in-effect" 1 @@ -390,6 +461,8 @@ "../../src/components/withNavigationTransitionEnd.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/withToggleVisibilityView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/withViewportOffsetTop.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/WorkspaceConfirmationForm.tsx" "no-restricted-imports" 1 +"../../src/components/WorkspaceMemberRoleList.tsx" "no-restricted-imports" 1 "../../src/components/ZeroWidthView/index.tsx" "no-restricted-syntax" 2 "../../src/CONST/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/CONST/runtimeConfigured.ts" "rulesdir/no-direct-personal-details-list" 1 @@ -425,7 +498,6 @@ "../../src/hooks/useDynamicBackPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useExportedToFilterOptions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useFilterFormValues.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/hooks/useGeographicalStateAndCountryFromRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/hooks/useHtmlPaste/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useHydrateReportsFromSnapshot.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useImportSpreadsheetConfirmModal.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -439,6 +511,7 @@ "../../src/hooks/useLazyAsset.ts" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useListKeyboardNav.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useMarkdownStyle.ts" "rulesdir/no-raw-typography" 3 +"../../src/hooks/useNativeCamera.ts" "@typescript-eslint/no-deprecated/Gesture.Tap" 2 "../../src/hooks/useNewTransactions.ts" "react-hooks/refs" 2 "../../src/hooks/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 "../../src/hooks/useOptimisticDraftTransactions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -459,7 +532,6 @@ "../../src/hooks/useReviewDuplicatesNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useReviewDuplicatesNavigation.tsx" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useSearchBulkActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 -"../../src/hooks/useSearchHighlightAndScroll.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/hooks/useSidebarOrderedReports.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/refs" 5 "../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/set-state-in-effect" 1 @@ -469,18 +541,15 @@ "../../src/hooks/useSubPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/hooks/useTackInputFocus/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useTextWithMiddleEllipsis.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/hooks/useTodoSearchResults.ts" "rulesdir/no-direct-personal-details-list" 1 "../../src/hooks/useTransactionViolationOfWorkspace.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useViolations.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useWindowDimensions/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useWorkletStateMachine/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/languages/flattenObject.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/Accessibility/moveAccessibilityFocus/types.ts" "@typescript-eslint/no-deprecated/ElementRef" 1 "../../src/libs/AccountingUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/__mocks__/App.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/__mocks__/App.ts" "no-restricted-syntax" 2 "../../src/libs/actions/__mocks__/OnyxUpdates.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/App.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/App.ts" "no-restricted-syntax" 1 "../../src/libs/actions/applyOnyxUpdatesReliably.ts" "no-restricted-syntax" 1 "../../src/libs/actions/BankAccounts.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -523,8 +592,7 @@ "../../src/libs/actions/IOU/Duplicate.ts" "no-restricted-syntax" 2 "../../src/libs/actions/IOU/Hold.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/actions/IOU/Hold.ts" "no-restricted-syntax" 2 -"../../src/libs/actions/IOU/index.ts" "rulesdir/no-direct-personal-details-list" 2 -"../../src/libs/actions/IOU/index.ts" "rulesdir/no-onyx-connect" 11 +"../../src/libs/actions/IOU/index.ts" "rulesdir/no-onyx-connect" 7 "../../src/libs/actions/IOU/MoneyRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/IOU/MoneyRequestBuilder.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2 "../../src/libs/actions/IOU/MoneyRequestBuilder.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 @@ -542,7 +610,6 @@ "../../src/libs/actions/IOU/ReportWorkflow.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 5 "../../src/libs/actions/IOU/ReportWorkflow.ts" "no-restricted-imports" 1 "../../src/libs/actions/IOU/ReportWorkflow.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/IOU/SearchUpdate.ts" "rulesdir/no-direct-personal-details-list" 2 "../../src/libs/actions/IOU/SendInvoice.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/IOU/SendInvoice.ts" "no-restricted-syntax" 1 "../../src/libs/actions/IOU/SendMoney.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -594,10 +661,10 @@ "../../src/libs/actions/Policy/Plan.ts" "no-restricted-syntax" 1 "../../src/libs/actions/Policy/Policy.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../src/libs/actions/Policy/Policy.ts" "no-restricted-imports" 2 -"../../src/libs/actions/Policy/Policy.ts" "no-restricted-syntax" 10 +"../../src/libs/actions/Policy/Policy.ts" "no-restricted-syntax" 9 "../../src/libs/actions/Policy/ReportField.ts" "no-restricted-imports" 1 "../../src/libs/actions/Policy/ReportField.ts" "no-restricted-syntax" 4 -"../../src/libs/actions/Policy/Rules.ts" "no-restricted-syntax" 3 +"../../src/libs/actions/Policy/Rules.ts" "no-restricted-syntax" 1 "../../src/libs/actions/Policy/Tag.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/Policy/Tag.ts" "no-restricted-imports" 1 "../../src/libs/actions/Policy/Tag.ts" "no-restricted-syntax" 4 @@ -611,8 +678,7 @@ "../../src/libs/actions/Report/index.ts" "@typescript-eslint/no-deprecated/reportAction.originalMessage" 1 "../../src/libs/actions/Report/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 19 "../../src/libs/actions/Report/index.ts" "no-restricted-syntax" 7 -"../../src/libs/actions/Report/index.ts" "rulesdir/no-direct-personal-details-list" 1 -"../../src/libs/actions/Report/index.ts" "rulesdir/no-onyx-connect" 3 +"../../src/libs/actions/Report/index.ts" "rulesdir/no-onyx-connect" 2 "../../src/libs/actions/Report/MarkAllMessageAsRead.tsx" "no-restricted-syntax" 1 "../../src/libs/actions/ReportLayout.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/ReportLayout.ts" "no-restricted-syntax" 1 @@ -630,7 +696,6 @@ "../../src/libs/actions/Tab.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/Task.ts" "no-restricted-imports" 1 "../../src/libs/actions/Task.ts" "no-restricted-syntax" 5 -"../../src/libs/actions/Task.ts" "rulesdir/no-direct-personal-details-list" 1 "../../src/libs/actions/TaxRate.ts" "no-restricted-syntax" 1 "../../src/libs/actions/TeachersUnite.ts" "no-restricted-syntax" 1 "../../src/libs/actions/Transaction.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2 @@ -651,11 +716,10 @@ "../../src/libs/API/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/API/makeRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/AttendeeUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/libs/Avatars/InitialAvatars.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/BankAccountUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/canFocusInputOnScreenFocus/index.ts" "no-restricted-syntax" 1 "../../src/libs/CardFeedUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 -"../../src/libs/CardUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 13 +"../../src/libs/CardUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 12 "../../src/libs/CategoryOptionListUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/clearSelectedTextIfComposerBlurred/clearSelectedTextIfComposerBlurred.web.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Clipboard/index.ts" "@typescript-eslint/no-deprecated/document.execCommand" 1 @@ -664,7 +728,6 @@ "../../src/libs/ComposerFocusManager.ts" "@typescript-eslint/no-deprecated/TextInput.State.currentlyFocusedField" 1 "../../src/libs/ComposerFocusManager.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/ComposerUtils/index.ts" "no-restricted-syntax" 1 -"../../src/libs/compoundParamsKey.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/CountryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/CurrencyUtils.ts" "rulesdir/no-onyx-connect" 1 "../../src/libs/DateUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -777,6 +840,7 @@ "../../src/libs/Navigation/OnyxTabNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/Navigation/PlatformStackNavigation/navigationOptions/animation/withAnimation.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/NetSuiteUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Network/enhanceParameters.ts" "no-restricted-syntax" 1 "../../src/libs/Network/index.ts" "no-restricted-syntax" 1 "../../src/libs/Network/LoadTestState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -791,16 +855,13 @@ "../../src/libs/ObjectUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-deprecated/translateLocal" 7 "../../src/libs/OptionsListUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/OptionsListUtils/index.ts" "rulesdir/no-onyx-connect" 1 -"../../src/libs/OptionsListUtils/searchMatchUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 2 "../../src/libs/PageHTMLCapture/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Parser.ts" "rulesdir/no-direct-personal-details-list" 1 -"../../src/libs/Parser.ts" "rulesdir/no-onyx-connect" 2 +"../../src/libs/Parser.ts" "rulesdir/no-onyx-connect" 1 "../../src/libs/ParsingUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/PaymentUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/PhoneNumber.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/PolicyUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 +"../../src/libs/PolicyUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../src/libs/prepareRequestPayload/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/libs/prepareRequestPayload/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Pusher/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 @@ -825,7 +886,6 @@ "../../src/libs/SearchQueryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 48 "../../src/libs/SearchUIUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 71 "../../src/libs/SearchUIUtils.ts" "no-restricted-imports" 1 -"../../src/libs/ShortMentionLogins.ts" "rulesdir/no-direct-personal-details-list" 1 "../../src/libs/SidebarUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Sound/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/SpendRulesUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -836,9 +896,11 @@ "../../src/libs/telemetry/forwardLogsToSentry.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/telemetry/TelemetrySynchronizer.ts" "rulesdir/no-direct-personal-details-list" 1 "../../src/libs/TransactionPreviewUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/TransactionUtils/duplicates.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/libs/TransactionUtils/getUpdatedTransaction.ts" "@typescript-eslint/no-deprecated/translateLocal" 2 "../../src/libs/TransactionUtils/index.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1 -"../../src/libs/TransactionUtils/index.ts" "@typescript-eslint/no-deprecated/translateLocal" 3 -"../../src/libs/TransactionUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 +"../../src/libs/TransactionUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/libs/TransactionUtils/tax.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 "../../src/libs/UnreadIndicatorUpdater/index.ts" "no-restricted-imports" 1 "../../src/libs/UnreadIndicatorUpdater/index.ts" "no-restricted-syntax" 1 "../../src/libs/updateMultilineInputRange/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -850,65 +912,97 @@ "../../src/libs/WorkspaceReportFieldUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/WorkspacesSettingsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/ONYXKEYS.ts" "no-restricted-syntax" 2 +"../../src/pages/AddExistingExpense.tsx" "no-restricted-imports" 1 "../../src/pages/AddPersonalBankAccountPage/substeps/AddressStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../src/pages/AddPersonalBankAccountPage/substeps/AddressStep.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/AddressPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/AddressPage.tsx" "no-restricted-imports" 1 "../../src/pages/AddressPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/ChronosScheduleOOOPage.tsx" "no-restricted-imports" 1 "../../src/pages/Debug/ConstantPicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/Debug/ConstantSelector.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/Debug/DateTimeSelector.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/Debug/DebugDetails.tsx" "@typescript-eslint/no-unsafe-type-assertion" 8 -"../../src/pages/Debug/Report/DebugReportActions.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/Debug/Report/DebugReportPage.tsx" "rulesdir/no-direct-personal-details-list" 2 +"../../src/pages/Debug/DynamicDebugDetailsConstantPickerPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Debug/DynamicDebugDetailsDateTimePickerPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Debug/Report/DebugReportPage.tsx" "no-restricted-imports" 1 "../../src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx" "no-restricted-imports" 1 +"../../src/pages/Debug/ReportAction/DebugReportActionPage.tsx" "no-restricted-imports" 1 "../../src/pages/Debug/ReportAction/DebugReportActionPreview.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/Debug/Transaction/DebugTransactionPage.tsx" "no-restricted-imports" 1 "../../src/pages/Debug/Transaction/DebugTransactionViolations.tsx" "no-restricted-syntax" 1 "../../src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx" "no-restricted-imports" 1 "../../src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/domain/Admins/DomainAddPrimaryContactPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/domain/Admins/DomainAdminDetailsPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/domain/Admins/DomainAdminsPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/domain/BaseDomainMemberDetailsComponent.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/domain/Members/DomainAddMemberPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/domain/Members/DomainMemberDetailsPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/domain/Members/DomainMembersPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/domain/Members/DomainReportSuspiciousActivityPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/domain/Members/MoveUserBetweenGroupsPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/domain/Members/TwoFactorAuth/DomainMemberForceTwoFactorAuthPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/domain/Members/TwoFactorAuth/DomainMemberResetTwoFactorAuthPage.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/AddDomainPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Admins/DomainAddAdminPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Admins/DomainAddPrimaryContactPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Admins/DomainAdminsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/BaseDomainMemberDetailsComponent.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/BaseDomainMembersPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/BaseDomainRequireTwoFactorAuthPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/BaseDomainSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/BaseDomainVerifiedPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/BaseVerifyDomainPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/DomainAccessRestrictedPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/DomainAddedPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/DomainAlreadyExistsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/DomainInitialPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/DomainResetPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/DomainSamlPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Groups/BaseDomainGroupPreferredWorkspacePage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Groups/DomainGroupCreatePage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Groups/DomainGroupDetailsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Groups/DomainGroupEditNamePage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Groups/DomainGroupsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Members/DomainAddMemberPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Members/DomainMembersPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Members/MoveUserBetweenGroupsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicEditReportFieldPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/DynamicReportChangeWorkspacePage.tsx" "rulesdir/no-direct-personal-details-list" 3 -"../../src/pages/DynamicReportDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/pages/DynamicReportDetailsPage.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/DynamicEditReportFieldPage.tsx" "no-restricted-imports" 1 +"../../src/pages/DynamicFlagCommentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/DynamicNewReportWorkspaceSelectionPage.tsx" "no-restricted-imports" 1 +"../../src/pages/DynamicReportChangeApproverPage.tsx" "no-restricted-imports" 1 +"../../src/pages/DynamicReportChangeWorkspacePage.tsx" "no-restricted-imports" 1 +"../../src/pages/DynamicReportDetailsPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/DynamicReportDetailsPage/index.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicReportParticipantDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/DynamicReportParticipantDetailsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/DynamicReportParticipantRoleSelectionPage.tsx" "no-restricted-imports" 1 +"../../src/pages/DynamicReportParticipantsInvitePage.tsx" "no-restricted-imports" 1 +"../../src/pages/DynamicReportParticipantsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/DynamicRoomInvitePage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicRoomMemberDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/DynamicRoomMemberDetailsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/DynamicRoomMembersPage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicRoomMembersPage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/DynamicRoomMembersPage.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/DynamicWorkspaceCurrencySelectionPage.tsx" "no-restricted-imports" 1 +"../../src/pages/EnablePayments/Pay/Activate/ActivateStep.tsx" "no-restricted-imports" 1 +"../../src/pages/EnablePayments/Pay/AdditionalDetails/AdditionalDetailsStep.tsx" "no-restricted-imports" 1 +"../../src/pages/EnablePayments/Pay/EnablePaymentsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/EnablePayments/Pay/Onfido/OnfidoStep.tsx" "no-restricted-imports" 1 +"../../src/pages/EnablePayments/Pay/Terms/TermsStep.tsx" "no-restricted-imports" 1 "../../src/pages/EnablePayments/shared/IdologyQuestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/EnablePayments/Wallet/AddBankAccount/AddBankAccount.tsx" "no-restricted-imports" 1 +"../../src/pages/EnablePayments/Wallet/EnablePayments.tsx" "no-restricted-imports" 1 "../../src/pages/EnablePayments/Wallet/PersonalInfo/substeps/DateOfBirthStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/EnablePayments/Wallet/utils/getSubstepValues.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx" "no-restricted-imports" 1 +"../../src/pages/GroupChatNameEditPage.tsx" "no-restricted-imports" 1 "../../src/pages/home/ForYouSection/ConciergePromptBox.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts" "no-restricted-imports" 1 "../../src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/home/YourSpendSection/useYourSpendData.ts" "no-restricted-imports" 1 -"../../src/pages/inbox/AccountManagerBanner.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/inbox/AgentZeroStatusContext.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/inbox/cleanStaleReportActionBackToParam.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/pages/inbox/ConciergeDraftContext.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/inbox/HeaderView.tsx" "rulesdir/no-direct-personal-details-list" 2 "../../src/pages/inbox/hooks/useReportWasDeleted.ts" "react-hooks/set-state-in-effect" 1 "../../src/pages/inbox/ReactionListWrapper.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/actionContents/ActionContentRouter.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/inbox/report/actionContents/JoinRequestContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/inbox/report/actionContents/JoinRequestContent.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/inbox/report/actionContents/MemberChangeContent.tsx" "rulesdir/no-direct-personal-details-list" 2 -"../../src/pages/inbox/report/actionContents/MentionWhisperContent.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/inbox/report/actionContents/ReimbursedContent.tsx" "rulesdir/no-direct-personal-details-list" 2 "../../src/pages/inbox/report/actionContents/ReimbursementDeQueuedContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/inbox/report/actionContents/ReimbursementQueuedContent.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/inbox/report/actionContents/RemovedFromApprovalChainContent.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/inbox/report/comment/TextCommentFragment.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 "../../src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx" "react-hooks/refs" 1 @@ -916,13 +1010,11 @@ "../../src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx" "react-hooks/refs" 28 -"../../src/pages/inbox/report/DelegateOnBehalfOfText.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/inbox/report/HumanAgentAssistedByText.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/inbox/report/DynamicReportDetailsExportPage.tsx" "no-restricted-imports" 1 "../../src/pages/inbox/report/LinkPreviewer.tsx" "@typescript-eslint/no-deprecated/fontSize" 3 "../../src/pages/inbox/report/ReactionList/HeaderReactionList.tsx" "no-restricted-syntax" 1 -"../../src/pages/inbox/report/ReactionList/PopoverReactionList.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx" "@typescript-eslint/no-deprecated/Gesture.Tap" 2 "../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "react-hooks/refs" 7 "../../src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx" "react-hooks/refs" 1 @@ -933,20 +1025,17 @@ "../../src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/pages/inbox/report/ReportActionItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/inbox/report/ReportActionItem.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/inbox/report/ReportActionItem.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/inbox/report/ReportActionItemCreated.tsx" "rulesdir/no-direct-personal-details-list" 3 "../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "no-restricted-syntax" 1 "../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "react-hooks/refs" 5 "../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/inbox/report/ReportActionItemParentAction.tsx" "rulesdir/no-direct-personal-details-list" 2 -"../../src/pages/inbox/report/ReportActionItemSingle.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/inbox/report/SearchActionHeader.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 "../../src/pages/inbox/report/shouldUseEmojiPickerSelection/index.web.ts" "no-restricted-syntax" 1 "../../src/pages/inbox/report/TripSummary.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/inbox/report/useDebouncedSaveDraft.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/WhisperBanner.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx" "no-restricted-imports" 1 "../../src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/withReportOrNotFound.tsx" "react-hooks/refs" 3 "../../src/pages/inbox/ReportHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -956,50 +1045,55 @@ "../../src/pages/inbox/sidebar/FABPopoverContent/menuItems/NewWorkspaceMenuItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/sidebar/FABPopoverContent/menuItems/TravelMenuItem.tsx" "no-restricted-imports" 1 "../../src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/iou/DynamicSplitBillDetailsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/iou/DynamicSplitExpenseCreateDateRangePage.tsx" "no-restricted-imports" 1 +"../../src/pages/iou/DynamicSplitExpenseEditPage.tsx" "no-restricted-imports" 1 "../../src/pages/iou/DynamicSplitExpensePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/iou/DynamicSplitExpensePage.tsx" "no-restricted-imports" 1 "../../src/pages/iou/DynamicSplitExpensePage.tsx" "react-hooks/set-state-in-effect" 3 +"../../src/pages/iou/HoldReasonFormView.tsx" "no-restricted-imports" 1 "../../src/pages/iou/MoneyRequestAmountForm.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/iou/RejectReasonFormView.tsx" "no-restricted-imports" 1 +"../../src/pages/iou/request/DistanceRequestStartPage.tsx" "no-restricted-imports" 1 +"../../src/pages/iou/request/IOURequestStartPage.tsx" "no-restricted-imports" 1 "../../src/pages/iou/request/ParticipantSearchResults.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/pages/iou/request/step/AmountSubmitDataSync.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/iou/request/step/DistanceManualTabContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/DynamicIOURequestEditReport.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepDate.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepDistanceRate.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/iou/request/step/DynamicIOURequestStepReport.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx" "no-restricted-imports" 1 "../../src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepTag.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepTaxRatePage.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx" "no-restricted-imports" 1 "../../src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx" "no-restricted-imports" 1 "../../src/pages/iou/request/step/IOURequestEditReportCommon.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepAmount.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/iou/request/step/IOURequestStepConfirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/iou/request/step/IOURequestStepConfirmation.tsx" "no-restricted-imports" 1 "../../src/pages/iou/request/step/IOURequestStepConfirmation.tsx" "react-hooks/preserve-manual-memoization" 1 -"../../src/pages/iou/request/step/IOURequestStepDistanceGPS/GPSButtons/index.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 3 -"../../src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/iou/request/step/IOURequestStepCurrencyModal.tsx" "no-restricted-imports" 1 "../../src/pages/iou/request/step/IOURequestStepDistanceMap.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 "../../src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx" "@typescript-eslint/no-deprecated/GestureType" 1 "../../src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx" "no-restricted-imports" 1 "../../src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/IOURequestStepScan/utils/buildReceiptFiles.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepScan/utils/getFileSource.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/iou/request/step/StepScreenDragAndDropWrapper.tsx" "no-restricted-imports" 1 "../../src/pages/iou/request/step/StepScreenWrapper.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/iou/request/step/StepScreenWrapper.tsx" "no-restricted-imports" 1 "../../src/pages/iou/request/step/withFullTransactionOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/withWritableReportOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/iou/SplitList/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/LoadingPage.tsx" "no-restricted-imports" 1 "../../src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx" "no-restricted-imports" 1 "../../src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx" "react-hooks/set-state-in-effect" 3 "../../src/pages/media/AttachmentModalScreen/AttachmentModalContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/media/AttachmentModalScreen/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 16 @@ -1007,15 +1101,25 @@ "../../src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx" "react-hooks/set-state-in-effect" 2 "../../src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx" "no-restricted-imports" 1 "../../src/pages/MissingPersonalDetails/MissingPersonalDetailsValidateCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/MissingPersonalDetails/subPages/Address.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/pages/MissingPersonalDetails/subPages/Address.tsx" "react-hooks/refs" 4 "../../src/pages/MultifactorAuthentication/AuthorizeTransactionPage/AuthorizeCardTransactionPreview.tsx" "@typescript-eslint/no-deprecated/fontSize" 2 +"../../src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx" "no-restricted-imports" 1 +"../../src/pages/MultifactorAuthentication/PromptPage.tsx" "no-restricted-imports" 1 +"../../src/pages/MultifactorAuthentication/RevokePage.tsx" "no-restricted-imports" 1 +"../../src/pages/MultifactorAuthentication/ValidateCodePage.tsx" "no-restricted-imports" 1 +"../../src/pages/NewChatConfirmPage.tsx" "no-restricted-imports" 1 +"../../src/pages/NewChatSelectorPage.tsx" "no-restricted-imports" 1 "../../src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/PrivateNotes/PrivateNotesEditPage.tsx" "no-restricted-imports" 1 +"../../src/pages/PrivateNotes/PrivateNotesListPage.tsx" "no-restricted-imports" 1 "../../src/pages/ProfilePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/ProfilePage.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/ProfilePage.tsx" "no-restricted-imports" 1 "../../src/pages/ReimbursementAccount/AddressFormFields.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ReimbursementAccount/ConnectedVerifiedBankAccount.tsx" "no-restricted-imports" 1 "../../src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/Address.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/AccountHolderDetails.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/BankAccountDetails.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 @@ -1027,6 +1131,7 @@ "../../src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/ContactInformation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/IncorporationLocation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Website.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx" "no-restricted-imports" 1 "../../src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Address.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/ReimbursementAccount/NonUSD/utils/getBankInfoStepValues.ts" "@typescript-eslint/no-unsafe-type-assertion" 11 "../../src/pages/ReimbursementAccount/NonUSD/utils/getInitialSubStepForBankInfoStep.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1034,6 +1139,7 @@ "../../src/pages/ReimbursementAccount/NonUSD/utils/getInputKeysForBankInfoStep.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/ReimbursementAccount/NonUSD/utils/getValuesForBeneficialOwner.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "no-restricted-imports" 1 "../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "react-hooks/refs" 3 "../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "react-hooks/set-state-in-effect" 4 "../../src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1041,71 +1147,206 @@ "../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IndustryCode/IndustryCodeSelector.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx" "no-restricted-imports" 1 "../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/WebsiteBusiness.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ReimbursementAccount/USD/ConnectBankAccount/ConnectBankAccount.tsx" "no-restricted-imports" 1 "../../src/pages/ReimbursementAccount/utils/getSubStepValues.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint.tsx" "no-restricted-imports" 1 +"../../src/pages/RejectExpenseReportPage.tsx" "no-restricted-imports" 1 "../../src/pages/ReportDescriptionPage.tsx" "no-restricted-imports" 1 "../../src/pages/ReportDescriptionPage.tsx" "no-restricted-syntax" 1 -"../../src/pages/ReportSubmitToContent.tsx" "rulesdir/no-direct-personal-details-list" 2 +"../../src/pages/RestrictedAction/Workspace/WorkspaceAdminRestrictedAction.tsx" "no-restricted-imports" 1 +"../../src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx" "no-restricted-imports" 1 +"../../src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx" "no-restricted-imports" 1 +"../../src/pages/RestrictedAction/Workspace/WorkspaceUserRestrictedAction.tsx" "no-restricted-imports" 1 +"../../src/pages/RoomDescriptionPage.tsx" "no-restricted-imports" 1 +"../../src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx" "no-restricted-imports" 1 +"../../src/pages/ScheduleCall/ScheduleCallPage.tsx" "no-restricted-imports" 1 "../../src/pages/Search/SavedSearchRenamePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/Search/SavedSearchRenamePage.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx" "no-restricted-imports" 1 "../../src/pages/Search/SearchChangeApproverPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/Search/SearchChangeApproverPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx" "no-restricted-imports" 1 "../../src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage.tsx" "no-restricted-imports" 1 "../../src/pages/Search/SearchEditMultiple/SearchEditMultipleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/Search/SearchMoneyRequestReportPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/Search/SearchPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/Search/SearchPageNarrow/index.tsx" "no-restricted-imports" 1 +"../../src/pages/Search/SearchSavePage.tsx" "no-restricted-imports" 1 "../../src/pages/Search/SearchTransactionsChangeReport.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/SetDefaultWorkspacePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/AboutPage/AboutPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/AboutPage/AboutPage.tsx" "react-hooks/refs" 1 -"../../src/pages/settings/Agents/EditAgentPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/settings/Agents/Fields/EditNamePage.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/settings/Agents/AddAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/AgentsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/EditAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/Fields/EditNamePage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/Fields/EditPromptPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Agents/Fields/EditPromptPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/NewAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/AppDownloadLinks.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Copilot/CopilotPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/settings/Copilot/CopilotPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/DynamicKeyboardShortcutsPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/ExitSurvey/DynamicExitSurveyReasonPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/HelpPage/HelpPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Preferences/LanguagePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Preferences/PaymentCurrencyPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Preferences/PreferencesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Preferences/PriorityModePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Preferences/ThemePage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/AgentAIPromptSection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Profile/Avatar/UserProfileAvatar.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Profile/Contacts/DynamicContactMethodDetailsPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/Contacts/DynamicContactMethodDetailsPage.tsx" "react-hooks/refs" 3 "../../src/pages/settings/Profile/Contacts/DynamicContactMethodDetailsPage.tsx" "react-hooks/set-state-in-effect" 3 +"../../src/pages/settings/Profile/Contacts/DynamicContactMethodsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Profile/Contacts/DynamicNewContactMethodPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Profile/CustomStatus/SetDatePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Profile/CustomStatus/SetTimePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/settings/Profile/CustomStatus/StatusPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/CustomStatus/StatusPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Profile/DisplayNamePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Profile/PersonalDetails/BaseLegalNamePage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/PersonalDetails/DynamicStateSelectionPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/settings/Profile/PersonalDetails/DynamicStateSelectionPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsConfirmValidateCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 8 +"../../src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/ProfilePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Profile/ProfilePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Profile/PronounsPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/PronounsPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/settings/Profile/TimezoneInitialPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Profile/TimezoneInitialPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/TimezoneSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Profile/TimezoneSelectPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Report/DynamicNotificationPreferencePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Report/DynamicReportSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Report/DynamicVisibilityPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Report/DynamicWriteCapabilityPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Report/RoomNamePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Rules/ExpenseRulesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Rules/RulePageBase.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Security/AddDelegate/ConfirmDelegateValidateCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Security/AddDelegate/SelectDelegateRolePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateValidateCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Security/CloseAccount/CloseAccountPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Security/DeviceManagementPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Security/LockAccount/FailedToLockAccountPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Security/LockAccount/UnlockAccountPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Security/MergeAccounts/AccountDetailsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Security/MergeAccounts/AccountValidatePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Security/SecuritySettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthWrapper.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Subscription/CardAuthenticationModal/index.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Subscription/CardAuthenticationModal/index.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/settings/Subscription/DisableAutoRenewSurveyPage/index.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Subscription/ExpensifyCodePage/index.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx" "no-restricted-syntax" 1 "../../src/pages/settings/Subscription/PaymentCard/DynamicPaymentCardCurrencySelectorPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Subscription/PaymentCard/DynamicPaymentCardCurrencySelectorPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Subscription/PaymentCard/index.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Subscription/SubscriptionPlan/ComparePlansModal.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Subscription/SubscriptionPlan/ComparePlansModal.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/settings/Subscription/SubscriptionPlan/DynamicSubscriptionPlanDowngradeBlockedPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Subscription/SubscriptionSettings/index.native.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Subscription/SubscriptionSettings/index.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Subscription/SubscriptionSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Subscription/SubscriptionSize/index.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Troubleshoot/DynamicBetaOverridesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Troubleshoot/ServerSelector.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Troubleshoot/TroubleshootPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/VerifyAccountPageBase.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/AddCardToDigitalWalletPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/AddDebitCardPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/BankAccountPurposePage/index.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/BankAccountPurposePage/substeps/CountrySelection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Wallet/CardAddedToWalletPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/ChooseTransferAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Wallet/ChooseTransferAccountPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/CountrySelectionList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsAgreementsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Wallet/ExpensifyCardPage/ChangePINPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/ExpensifyCardPage/index.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/ImportTransactionsCardNamePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/ImportTransactionsCurrencyPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/ImportTransactionsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/InternationalDepositAccount/subPages/AccountFlowEntryPoint.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Wallet/InternationalDepositAccount/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Wallet/PaymentMethodList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/pages/settings/Wallet/PersonalCardDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/settings/Wallet/PersonalCardDetailsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/PersonalCardEditNamePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage/index.native.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage/index.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage/useFixPersonalCardConnection.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/settings/Wallet/PersonalCards/steps/BankConnection/index.native.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/PersonalCards/steps/BankConnection/index.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/PersonalCards/steps/SelectBankStep.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Wallet/PersonalCards/steps/SuccessStep.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/ReportCardLostConfirmValidateCodePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/ReportCardLostPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/ShareBankAccount/ShareBankAccount.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/ShareBankAccount/ShareBankAccount.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/settings/Wallet/TransactionsImportedPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/TransferBalancePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/UpdatePersonalBankAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Wallet/UpdatePersonalBankAccountPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/WalletPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/settings/Wallet/WalletPage/index.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Wallet/WalletPage/index.tsx" "no-restricted-imports" 2 "../../src/pages/settings/Wallet/WalletPage/index.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/Share/ShareDetailsPage.tsx" "no-restricted-imports" 1 "../../src/pages/Share/ShareRootPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/Share/ShareRootPage.tsx" "no-restricted-imports" 1 "../../src/pages/Share/ShareTab.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/Share/SubmitDetailsPage.tsx" "no-restricted-imports" 1 "../../src/pages/ShareCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/ShareCodePage.tsx" "no-restricted-imports" 1 "../../src/pages/signin/SAMLSignInPage/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/signin/SAMLSignInPage/index.native.tsx" "no-restricted-imports" 1 +"../../src/pages/signin/SignInModal.tsx" "no-restricted-imports" 1 "../../src/pages/signin/SignInPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/signin/SignInPageLayout/BackgroundImage/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/signin/SignInPageLayout/FooterRow/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1113,32 +1354,69 @@ "../../src/pages/signin/SignInPageLayout/SignInPageContent.tsx" "rulesdir/no-raw-typography" 2 "../../src/pages/signin/SMSDeliveryFailurePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx" "react-hooks/set-state-in-effect" 4 -"../../src/pages/tasks/DynamicNewTaskDetailsPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/tasks/DynamicNewTaskPage.tsx" "rulesdir/no-direct-personal-details-list" 2 +"../../src/pages/tasks/DynamicNewTaskDescriptionPage.tsx" "no-restricted-imports" 1 +"../../src/pages/tasks/DynamicNewTaskDetailsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/tasks/DynamicNewTaskPage.tsx" "no-restricted-imports" 1 +"../../src/pages/tasks/DynamicNewTaskTitlePage.tsx" "no-restricted-imports" 1 +"../../src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx" "no-restricted-imports" 1 "../../src/pages/tasks/DynamicTaskShareDestinationSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/tasks/DynamicTaskShareDestinationSelectorModal.tsx" "no-restricted-imports" 1 +"../../src/pages/tasks/TaskDescriptionPage.tsx" "no-restricted-imports" 1 +"../../src/pages/tasks/TaskTitlePage.tsx" "no-restricted-imports" 1 +"../../src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx" "no-restricted-imports" 1 +"../../src/pages/TeachersUnite/IntroSchoolPrincipalPage.tsx" "no-restricted-imports" 1 +"../../src/pages/TeachersUnite/KnowATeacherPage.tsx" "no-restricted-imports" 1 "../../src/pages/TeachersUnite/SaveTheWorldPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/TeachersUnite/SaveTheWorldPage.tsx" "no-restricted-imports" 1 "../../src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 "../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "no-restricted-imports" 1 +"../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "no-restricted-imports" 2 "../../src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx" "react-hooks/refs" 12 +"../../src/pages/TransactionDuplicate/DynamicReviewBillablePage.tsx" "no-restricted-imports" 1 +"../../src/pages/TransactionDuplicate/DynamicReviewCategoryPage.tsx" "no-restricted-imports" 1 +"../../src/pages/TransactionDuplicate/DynamicReviewDescriptionPage.tsx" "no-restricted-imports" 1 +"../../src/pages/TransactionDuplicate/DynamicReviewMerchantPage.tsx" "no-restricted-imports" 1 +"../../src/pages/TransactionDuplicate/DynamicReviewPage.tsx" "no-restricted-imports" 1 +"../../src/pages/TransactionDuplicate/DynamicReviewReimbursablePage.tsx" "no-restricted-imports" 1 +"../../src/pages/TransactionDuplicate/DynamicReviewTagPage.tsx" "no-restricted-imports" 1 +"../../src/pages/TransactionDuplicate/DynamicReviewTaxCodePage.tsx" "no-restricted-imports" 1 "../../src/pages/TransactionDuplicate/ReviewFields.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 "../../src/pages/TransactionMerge/DynamicConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/TransactionMerge/DynamicConfirmationPage.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/TransactionMerge/DynamicConfirmationPage.tsx" "no-restricted-imports" 1 "../../src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx" "no-restricted-imports" 1 +"../../src/pages/TransactionMerge/DynamicMergeTransactionsListPage.tsx" "no-restricted-imports" 1 +"../../src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx" "no-restricted-imports" 1 "../../src/pages/TransactionMerge/MergeTransactionItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/TransactionMerge/MergeTransactionsListContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/Travel/DynamicTravelUpgrade.tsx" "no-restricted-imports" 1 -"../../src/pages/Travel/DynamicTripDetailsPage.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/Travel/DynamicDomainPermissionInfoPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Travel/DynamicPublicDomainErrorPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Travel/DynamicTravelUpgrade.tsx" "no-restricted-imports" 2 +"../../src/pages/Travel/DynamicTripSummaryPage.tsx" "no-restricted-imports" 1 "../../src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/Travel/EnableTravel/EnableTravelContent.tsx" "no-restricted-imports" 1 +"../../src/pages/Travel/MyTripsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/Travel/TravelDotLinkWebview.tsx" "no-restricted-imports" 1 +"../../src/pages/TripChatNameEditPage.tsx" "no-restricted-imports" 1 "../../src/pages/UnreportedExpenseListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/ValidateLoginPage/index.web.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/wallet/WalletStatementPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/AccountingContext/default.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/AccountingSetupWebViewPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/accounting/businessCentral/BusinessCentralPrerequisitesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/accounting/campfire/CampfireExistingConnectionsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/accounting/campfire/export/CampfireExportPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/certinia/CertiniaExistingConnectionsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/certinia/import/CertiniaDimensionMappingPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/ClaimOfferPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/ClaimOfferPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/accounting/dualentry/DualEntryExistingConnectionsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/dualentry/export/DualEntryExportPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/intacct/advanced/DynamicSageIntacctAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/accounting/intacct/EnterSageIntacctCredentialsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/accounting/intacct/ExistingConnectionsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/intacct/export/DynamicSageIntacctNonReimbursableExpensesDestinationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/intacct/export/SageIntacctDatePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/intacct/export/SageIntacctReimbursableExpensesDestinationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1147,6 +1425,7 @@ "../../src/pages/workspace/accounting/intacct/import/SageIntacctMappingsTypePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAutoSyncPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/netsuite/advanced/NetSuiteCustomFormIDPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/advanced/NetSuiteExpenseReportApprovalLevelSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/advanced/NetSuiteJournalEntryApprovalLevelSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1159,6 +1438,7 @@ "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectsPage.tsx" "no-restricted-syntax" 2 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectsPage.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldEdit.tsx" "@typescript-eslint/no-unsafe-type-assertion" 12 +"../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ChooseSegmentTypeStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomSegmentList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1168,6 +1448,7 @@ "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportMappingPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenSetupContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/accounting/PolicyAccountingPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/accounting/PolicyAccountingPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/PolicyAccountingPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountSelectCardPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1186,9 +1467,13 @@ "../../src/pages/workspace/accounting/qbd/import/QuickbooksDesktopItemsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/qbd/import/QuickbooksDesktopItemsPage.tsx" "no-restricted-syntax" 3 "../../src/pages/workspace/accounting/qbd/import/QuickbooksDesktopItemsPage.tsx" "rulesdir/no-default-id-values" 1 +"../../src/pages/workspace/accounting/qbd/QuickbooksDesktopExistingConnectionsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx" "rulesdir/no-default-id-values" 2 +"../../src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/workspace/accounting/qbd/RequireQuickBooksDesktopPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/qbo/advanced/DynamicQuickbooksOnlineAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/qbo/advanced/DynamicQuickbooksOnlineAutoSyncPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountSelectCardPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseEntitySelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/qbo/import/QuickbooksChartOfAccountsPage.tsx" "no-restricted-syntax" 2 @@ -1202,9 +1487,12 @@ "../../src/pages/workspace/accounting/qbo/import/QuickbooksTaxesPage.tsx" "no-restricted-syntax" 2 "../../src/pages/workspace/accounting/qbo/import/QuickbooksTaxesPage.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/rillet/export/RilletExportPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/rillet/RilletExistingConnectionsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/utils.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/xero/advanced/DynamicXeroAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/accounting/xero/advanced/DynamicXeroAutoSyncPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillStatusSelectorPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/xero/import/XeroChartOfAccountsPage.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 @@ -1221,71 +1509,228 @@ "../../src/pages/workspace/accounting/xero/XeroTaxesConfigurationPage.tsx" "no-restricted-syntax" 4 "../../src/pages/workspace/accounting/xero/XeroTaxesConfigurationPage.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/categories/CategoryGLCodePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/CategoryPayrollCodePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/CategorySettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicCategoryApproverPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicCategoryDefaultTaxRatePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicCategoryDescriptionHintPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicCategoryFlagAmountsOverPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicCategoryRequiredFieldsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicCategoryRequireItemizedReceiptsOverPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicCategoryRequireReceiptsOverPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicCreateCategoryPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicDefaultCategorySelectorPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicEditCategoryPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicExpenseLimitTypeSelectorPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicSpendCategorySelectorPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/ImportedCategoriesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/categories/WorkspaceCategoriesPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/categories/WorkspaceCategoriesPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/workspace/companyCards/addNew/AmexCustomFeed.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/addNew/CardNameStep.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/addNew/CardTypeStep.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/addNew/CompanyCardLayoutNamePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/addNew/DetailsStep.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/addNew/SelectBankStep.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/addNew/SelectFeedType.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/assignCard/CardNameStep.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/BankConnection/index.native.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/BankConnection/index.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/companyCards/BankConnection/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/companyCards/WorkspaceCompanyCardAddWorkEmailPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/companyCards/WorkspaceCompanyCardAddWorkEmailPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/WorkspaceCompanyCardEditCardNamePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsFeedNamePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDateSelectionList/index.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/companyCards/WorkspaceVerifyWorkAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/companyCards/WorkspaceVerifyWorkAccountPage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectWorkspacesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/copyPolicySettings/CopyPolicySettingsUpgradePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/distanceRates/CreateDistanceRatePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/distanceRates/PolicyDistanceRateDateEditPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/distanceRates/PolicyDistanceRateNameEditPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/distanceRates/PolicyDistanceRateTaxRateEditPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/distanceRates/PolicyDistanceRateUnitPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/downgrade/DynamicWorkspaceDowngradePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/duplicate/WorkspaceDuplicateForm.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/DynamicWorkspaceInvitePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/workspace/DynamicWorkspacePlanTypeSelectionPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/expensifyCard/DynamicExpensifyCardNamePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/expensifyCard/issueNew/spendRules/SetSpendRulesStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/expensifyCard/issueNew/spendRules/SpendRuleSelectionPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/expensifyCard/WorkspaceExpensifyCardAddWorkEmailPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/fields/CreateFieldsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/fields/FieldsAddListValuePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/fields/FieldsEditValuePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/fields/FieldsInitialListValuePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/fields/FieldsInitialValuePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/fields/FieldsListValuesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/fields/FieldsSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/fields/FieldsTypeSelectorPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/fields/FieldsValueSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/hr/HRApprovalModePageBase.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/hr/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/invoices/WorkspaceInvoicingDetailsName.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/mcp/WorkspaceMCPPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/members/DynamicWorkspaceOwnerChangeErrorPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/DynamicWorkspaceOwnerChangeErrorPage.tsx" "react-hooks/refs" 2 +"../../src/pages/workspace/members/DynamicWorkspaceOwnerChangeSuccessPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/DynamicWorkspaceOwnerChangeSuccessPage.tsx" "react-hooks/refs" 2 +"../../src/pages/workspace/members/DynamicWorkspaceOwnerChangeWrapperPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/members/ImportedMembersConfirmationPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/ImportedMembersConfirmationPage.tsx" "react-hooks/refs" 7 +"../../src/pages/workspace/members/ImportedMembersPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/ImportMembersPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/ImportMembersPage.tsx" "no-restricted-syntax" 1 "../../src/pages/workspace/members/ImportMembersPage.tsx" "rulesdir/no-default-id-values" 1 +"../../src/pages/workspace/members/WorkArrangementPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/WorkspaceOwnerChangeCheck.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx" "react-hooks/set-state-in-effect" 2 +"../../src/pages/workspace/merge/MergeConnectionsPageBase.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/perDiem/EditPerDiemAmountPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/perDiem/EditPerDiemDestinationPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/perDiem/EditPerDiemSubratePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/perDiem/ImportedPerDiemPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/perDiem/WorkspacePerDiemDetailsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/receiptPartners/ChangeReceiptBillingAccountPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/receiptPartners/DynamicInviteReceiptPartnerPolicyPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/reports/ReportsDefaultTitle.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/reports/TypeSelector/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/workspace/reports/WorkspaceReportsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/rooms/WorkspaceRoomsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/tags/DynamicEditTagPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/tags/DynamicTagApproverPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/tags/DynamicTagGLCodePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/tags/DynamicTagSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/tags/DynamicWorkspaceEditTagsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/tags/DynamicWorkspaceViewTagsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/tags/ImportedMultiLevelTagsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/tags/ImportedTagsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/tags/ImportTagsOptionsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/tags/WorkspaceCreateTagPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/taxes/NamePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/taxes/ValuePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/taxes/WorkspaceCreateTaxValuePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/taxes/WorkspaceTaxCodePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/taxes/WorkspaceTaxesPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/taxes/WorkspaceTaxesPage.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/timeTracking/WorkspaceTimeTrackingDefaultRatePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/travel/PolicyTravelPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/travel/WorkspaceTravelBillingExportPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/travel/WorkspaceTravelBillingFeedSelectorPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/travel/WorkspaceTravelBillingMonthlyLimitPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/travel/WorkspaceTravelBillingSettlementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/travel/WorkspaceTravelBillingSettlementAccountPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/travel/WorkspaceTravelBillingSettlementFrequencyPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/upgrade/UpgradeIntro.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx" "no-restricted-imports" 2 +"../../src/pages/workspace/vendors/WorkspaceVendorsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/withPolicy.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/withPolicyAndFullscreenLoading.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/withPolicyConnections.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/workflows/WorkspaceWorkflowsCurrencyConversionFeesPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/WorkspaceConfirmationOwnerSelectorPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/WorkspaceConfirmationSuccessPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/WorkspaceInitialPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceMembersPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/WorkspaceMembersPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/WorkspaceNamePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "react-hooks/set-state-in-effect" 3 +"../../src/pages/workspace/WorkspaceOverviewClientIDPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/WorkspaceOverviewDescriptionPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceOverviewPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/WorkspaceOverviewSharePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/WorkspacesListPage.tsx" "rulesdir/no-direct-personal-details-list" 1 +"../../src/pages/workspace/WorkspaceOverviewSharePage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/WorkspacePageWithSections.tsx" "no-restricted-imports" 2 "../../src/polyfills/requestIdleCallback.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/ROUTES.ts" "@typescript-eslint/no-deprecated/getUrlWithBackToParam" 52 +"../../src/ROUTES.ts" "@typescript-eslint/no-deprecated/getUrlWithBackToParam" 53 "../../src/ROUTES.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/selectors/AdvancedSearchFiltersForm.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/selectors/Domain.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1298,6 +1743,7 @@ "../../src/stories/DragAndDrop.stories.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/stories/EReceipt.stories.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/stories/Form.stories.tsx" "@typescript-eslint/no-unsafe-type-assertion" 10 +"../../src/stories/HeaderWithBackButton.stories.tsx" "no-restricted-imports" 2 "../../src/stories/ReportActionItemImages.stories.tsx" "no-restricted-syntax" 1 "../../src/stories/TextInput.stories.tsx" "react-hooks/set-state-in-effect" 1 "../../src/styles/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1392,8 +1838,11 @@ "../../tests/actions/TaskTest.ts" "no-restricted-imports" 1 "../../tests/actions/TransactionTest.ts" "no-restricted-imports" 1 "../../tests/ui/components/IOURequestStepConfirmationPageTest.tsx" "@typescript-eslint/no-restricted-imports" 1 +"../../tests/ui/components/MoneyReportHeaderFocusSelectionModeTest.tsx" "no-restricted-imports" 1 +"../../tests/ui/components/MoneyReportHeaderSelectionModeTest.tsx" "no-restricted-imports" 1 "../../tests/ui/ComposerTest.tsx" "rulesdir/no-raw-typography" 1 "../../tests/ui/MoneyRequestReportPreview.test.tsx" "no-restricted-imports" 1 +"../../tests/ui/ServerSelectionTest.tsx" "no-restricted-imports" 1 "../../tests/ui/UnreadIndicatorsTest.tsx" "@typescript-eslint/no-restricted-imports" 1 "../../tests/ui/WorkspaceMoreFeaturesPageTest.tsx" "no-restricted-imports" 1 "../../tests/unit/canEditFieldOfMoneyRequestTest.ts" "@typescript-eslint/no-deprecated/randomReportAction.originalMessage" 3 diff --git a/config/rsbuild/BrotliCompressionPlugin.ts b/config/rsbuild/BrotliCompressionPlugin.ts new file mode 100644 index 000000000000..0d3cbf69fbdc --- /dev/null +++ b/config/rsbuild/BrotliCompressionPlugin.ts @@ -0,0 +1,55 @@ +import type {Compiler} from '@rspack/core'; + +import {promisify} from 'util'; +import zlib from 'zlib'; + +const PLUGIN_NAME = 'BrotliCompressionPlugin'; + +const brotliCompress = promisify(zlib.brotliCompress); + +type Options = { + /** Every emitted asset whose name matches gets a `.br` twin. */ + test: RegExp; +}; + +/** + * Rspack plugin that writes a Brotli 11 twin (`foo.js` -> `foo.js.br`) beside every emitted asset matching `test`. + * Every match gets one, however small or incompressible: the CDN rewrite appends `.br` blindly, and a missing twin doesn't even 404 — the SPA fallback answers 200 + * with the app shell, so the browser either fails to decode that HTML as Brotli or refuses to run it as a script. + */ +class BrotliCompressionPlugin { + private readonly options: Options; + + constructor(options: Options) { + this.options = options; + } + + apply(compiler: Compiler): void { + const {Compilation, sources} = compiler.rspack; + + // `thisCompilation` skips child compilations, whose assets end up in the parent's anyway. + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.processAssets.tapPromise( + // OPTIMIZE_TRANSFER runs after minification, content hashing, HTML and the service worker are emitted, + // so the twins are made from the final bytes that will be deployed. + {name: PLUGIN_NAME, stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER}, + async () => { + const assets = compilation.getAssets().filter(({name, info}) => !info.compressed && this.options.test.test(name)); + + // zlib runs each compression on the libuv thread pool, so these proceed in parallel. + await Promise.all( + assets.map(async ({name, source, info}) => { + const compressed = await brotliCompress(source.buffer(), {params: {[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY}}); + + // `compressed` is the webpack convention that tells other plugins (and this one on a rebuild) not to + // compress the twin again. A twin of a content-hashed file is as immutable as its original. + compilation.emitAsset(`${name}.br`, new sources.RawSource(compressed), {compressed: true, immutable: info.immutable}); + }), + ); + }, + ); + }); + } +} + +export default BrotliCompressionPlugin; diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index 5fa24d76aa5c..f610e45b943e 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -5,6 +5,7 @@ import {GenerateSW} from '@aaroon/workbox-rspack-plugin'; import {pluginSvgr} from '@rsbuild/plugin-svgr'; import {RsdoctorRspackPlugin} from '@rsdoctor/rspack-plugin'; import {rspack} from '@rspack/core'; +import canvaskitPackageJson from 'canvaskit-wasm/package.json' with {type: 'json'}; import {execSync} from 'child_process'; import dotenv from 'dotenv'; import fs from 'fs'; @@ -21,6 +22,8 @@ import SENTRY_APPLICATION_KEY from '../../src/libs/telemetry/sentryApplicationKe import getAppVersion from '../../src/libs/VersionUtils.ts'; // eslint-disable-line @dword-design/import-alias/prefer-alias import oxcReactCompilerConfig from '../babel/oxcReactCompilerConfig.js'; // @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig +import BrotliCompressionPlugin from './BrotliCompressionPlugin.ts'; +// @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig import CustomVersionFilePlugin from './CustomVersionFilePlugin.ts'; // @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig import ModuleInitTimingPlugin from './ModuleInitTimingPlugin.ts'; @@ -43,6 +46,17 @@ function getCurrentBranchName(): string { const localBranchName = getCurrentBranchName(); +/** + * CanvasKit ships as a matched pair: the JS glue (`canvaskit.js`, bundled into a content-hashed chunk) and + * the `canvaskit.wasm` binary it instantiates. The pair is only compatible within a single `canvaskit-wasm` + * release, so the binary must be served from a URL that changes with the release too. Otherwise a client can + * pair one deploy's glue with another deploy's binary (stale HTTP cache, or a tab that outlived a deploy and + * got claimed by the new service worker) and CanvasKit either fails to link (`LinkError: Import #N "a" "wd"`) + * or links against the wrong exports and resolves without its bindings (`PictureRecorder is not a constructor`). + * See https://github.com/Expensify/App/issues/102042. + */ +const CANVASKIT_WASM_FILENAME = `canvaskit-${canvaskitPackageJson.version}.wasm`; + /** * React Compiler + react-native-worklets loaders. */ @@ -137,6 +151,9 @@ function getDefineValues(file: string): DefinePluginOptions { // Expose the current git branch so the debug menu can display it in the browser tab title. // Empty string in non-development builds. __GIT_BRANCH__: JSON.stringify(isDevelopmentFile ? localBranchName : ''), + // Where `SkiaWebChart` tells CanvasKit to fetch its wasm binary from. Versioned so the glue in this + // bundle can never be paired with another release's binary (see `CANVASKIT_WASM_FILENAME`). + __CANVASKIT_WASM_URL__: JSON.stringify(`/${CANVASKIT_WASM_FILENAME}`), }; /* eslint-enable @typescript-eslint/naming-convention */ } @@ -327,6 +344,7 @@ const getSharedConfiguration = ({file = '.env', isDevServer = false}: Environmen */ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevServer = false}: Environment): Promise => { const isDevelopment = file === '.env' || file === '.env.development'; + const shouldCompressWithBrotli = !isDevelopment && file !== '.env.adhoc'; const shared = getSharedConfiguration({file, platform, isDevServer}); const sharedRspackTool = shared.tools?.rspack; const sentryWebpackPlugin = isDevelopment ? undefined : (await import('@sentry/webpack-plugin')).sentryWebpackPlugin; @@ -404,8 +422,10 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer {from: 'node_modules/pdfjs-dist/cmaps/', to: 'cmaps/'}, // Group‑IB web SDK injection file {from: 'web/snippets/gib.js', to: 'gib.js'}, - // CanvasKit WASM files for @shopify/react-native-skia web support (uses full version) - {from: 'node_modules/canvaskit-wasm/bin/full/canvaskit.wasm'}, + // CanvasKit WASM binary for @shopify/react-native-skia web support (uses the full build). Emitted + // under a versioned name so it can't be served stale against newer glue. The URL is passed to + // the app through the `__CANVASKIT_WASM_URL__` define above. + {from: 'node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', to: CANVASKIT_WASM_FILENAME}, ], }, html: { @@ -419,6 +439,9 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer }, }, performance: { + // Rsbuild's default exclusion, plus the `.br` twins BrotliCompressionPlugin emits below: listing them would + // double the report with a meaningless "gzipped size" of already-Brotli-compressed bytes. + printFileSize: {exclude: (asset) => /\.(?:map|LICENSE\.txt|d\.(?:ts|mts|cts)|br)$/.test(asset.name)}, // We have to load the whole lottie player to get the player to work in offline mode // heic-to library is used sparsely so we load it as a separate chunk to reduce initial bundle size // ExpensifyIcons/illustrations chunks are loaded eagerly for offline support @@ -498,10 +521,16 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer clientsClaim: true, skipWaiting: true, // Cap is generous on purpose: the vendor (~6.5 MiB), main (~5.5 MiB), - // authScreens.prefetch (~6.3 MiB) chunks and canvaskit.wasm (~7.6 MiB) are - // all critical for offline boot, so we precache the lot. Everything in the - // App build is content-hashed, so growth here only costs first-install bytes. - maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, + // authScreens.prefetch (~10.1 MiB) chunks and the canvaskit wasm (~7.7 MiB) are + // all critical for offline boot, so we precache the lot. JS chunks are + // content-hashed and the wasm is versioned (see `CANVASKIT_WASM_FILENAME`), + // so growth here only costs first-install bytes. Copied assets that keep a + // fixed name (e.g. `cmaps/`) are keyed by Workbox revision instead. + maximumFileSizeToCacheInBytes: 15 * 1024 * 1024, + // Workbox's defaults, plus the `.br` twins BrotliCompressionPlugin emits: the service + // worker requests the original URLs and the CDN transparently serves the Brotli copy, + // so adding the twins to the precache as well would download every chunk twice. + exclude: [/\.map$/, /^manifest.*\.js$/, /\.br$/], // Single-page app: any unmatched navigation should serve the cached app shell. navigateFallback: '/index.html', // Don't fall back for asset-like or .well-known requests. @@ -591,6 +620,9 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer : []), // This allows us to interactively inspect JS bundle contents, loader/plugin timings, and duplicate packages ...(process.env.ANALYZE_BUNDLE === 'true' ? [new RsdoctorRspackPlugin()] : []), + // Writes a Brotli 11 twin (`foo.js` -> `foo.js.br`) beside every deployable text/bytecode asset, so the CDN + // can serve it instead of compressing with gzip on the fly: 25-30% fewer bytes over the wire. + ...(shouldCompressWithBrotli ? [new BrotliCompressionPlugin({test: /\.(?:js|css|html|svg|wasm|ttf)$/})] : []), ); return afterShared; diff --git a/contributingGuides/REVIEWER_CHECKLIST.md b/contributingGuides/REVIEWER_CHECKLIST.md index c8cb021b0abc..4c3053fdac2d 100644 --- a/contributingGuides/REVIEWER_CHECKLIST.md +++ b/contributingGuides/REVIEWER_CHECKLIST.md @@ -40,6 +40,11 @@ - [ ] If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles: - [ ] I verified that all the inputs inside a form are aligned with each other. - [ ] I added `Design` label and/or tagged `@Expensify/design` so the design team can review the changes. +- [ ] If the PR adds or modifies the UI: + - [ ] I asked an AI agent to review the changes for accessibility issues and addressed its findings. + - [ ] I tested with a screen reader (VoiceOver on macOS) and verified all new/changed elements are reachable with a logical focus order. + - [ ] I verified all new/changed elements have meaningful accessible names and roles. + - [ ] I verified state changes are announced (e.g. checked/unchecked, expanded/collapsed, selected). - [ ] For any bug fix or new feature in this PR, I verified that sufficient [unit tests](https://github.com/Expensify/App/blob/main/tests/README.md) are included to prevent regressions in this flow. - [ ] If the `main` branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the `Test` steps. - [ ] I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR. diff --git a/contributingGuides/philosophies/DEPLOYING.md b/contributingGuides/philosophies/DEPLOYING.md index 69f6c0fb6c4b..e75098f6ec1d 100644 --- a/contributingGuides/philosophies/DEPLOYING.md +++ b/contributingGuides/philosophies/DEPLOYING.md @@ -15,20 +15,20 @@ This describes the deployment processes, QA cycles, and build workflows for the ### - All code MUST be deployed to staging before production We utilize a CI/CD deployment system built using [GitHub Actions](https://github.com/features/actions) to ensure that new code is automatically deployed to our users as fast as possible. All code is first deployed to our staging environments, where it undergoes quality assurance (QA) testing before it is deployed to production. -### - Pull requests SHOULD be deployed to staging immediately after merge -Typically, pull requests are deployed to staging immediately after they are merged if the checklist is unlocked. +### - Pull requests are deployed to staging when the current checklist is closed +The deploy checklist is locked from the moment it is created, so pull requests are not deployed to staging when they are merged. OSBotify comments on the merged PR, and it is deployed to staging together with everything else merged since the previous checklist when the current checklist is closed. If a change must reach staging sooner, it has to be cherry-picked. ### - StagingDeployCash MUST be used to track deployment progress Every time a PR is deployed to staging, it is added to a [special tracking issue](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3AStagingDeployCash) with the label `StagingDeployCash` (there will only ever be one open at a time). This tracking issue contains information about the new application version, a list of recently deployed pull requests, and any issues found on staging that are not present on production. -### - QA team MUST lock deploys during testing cycles -Every weekday at 9am PST, our QA team adds the `🔐LockCashDeploys🔐` label to that tracking issue, and that signifies that they are starting their daily QA cycle. They will perform both regular regression testing and the QA steps listed for every pull request on the `StagingDeployCash` checklist. +### - Staging deploys are locked during testing cycles +The `🔐LockCashDeploys🔐` label is added to the tracking issue by OSBotify when it is created, so staging deploys are locked for the whole QA cycle. Our QA team performs both regular regression testing and the QA steps listed for every pull request on the `StagingDeployCash` checklist. ### - Deploy blockers MUST prevent production deployment Once the `StagingDeployCash` is locked, we won't run any staging deploys until it is either unlocked, or we run a production deploy. If severe issues are found on staging that are not present on production, a new issue (or the PR that caused the issue) will be labeled with `DeployBlockerCash`, and added to the `StagingDeployCash` deploy checklist. -### - A hotfix MAY be deployed directly to staging using CP Staging label -If we want to resolve a deploy blocker by reverting a pull request or deploying a hotfix directly to the staging environment, we can merge a pull request with the `CP Staging` label. +### - A hotfix MAY be deployed directly to staging with a cherry-pick +If we want to resolve a deploy blocker by reverting a pull request or deploying a hotfix directly to the staging environment, a member of [@Expensify/mobile-deployers](https://github.com/orgs/Expensify/teams/mobile-deployers) can run the [`cherryPick` workflow](https://github.com/Expensify/App/actions/workflows/cherryPick.yml) on the merged pull request. The workflow applies the `CP Staging` label itself; adding the label manually does not trigger a cherry-pick. ### - Production deployment MUST be triggered by closing StagingDeployCash Once we have confirmed to the best of our ability that there are no deploy-blocking issues and that all our new features are working as expected on staging, we'll close the `StagingDeployCash`. That will automatically trigger a production deployment, open a new `StagingDeployCash` checklist, and deploy to staging any pull requests that were merged while the previous checklist was locked. @@ -44,29 +44,22 @@ The [preDeploy workflow](https://github.com/Expensify/App/blob/main/.github/work - Otherwise: - Create a new version by triggering the [`createNewVersion` workflow](https://github.com/Expensify/App/blob/main/.github/workflows/createNewVersion.yml) - Update the `staging` branch from main. -- Also, if the pull request has the `CP Staging` label, it will execute the [`cherryPick` workflow](https://github.com/Expensify/App/blob/main/.github/workflows/cherryPick.yml) to deploy the pull request directly to staging, even if the `StagingDeployCash` is locked. ### deploy -The [`deploy` workflow](https://github.com/Expensify/App/blob/main/.github/workflows/deploy.yml) is really quite simple. It runs when code is pushed to the `staging` or `production` branches, and: - -- If `staging` was updated, it creates a tag matching the new version, and pushes tags. -- If `production` was updated, it creates a GitHub Release for the new version. - -### platformDeploy -The [`platformDeploy` workflow](https://github.com/Expensify/App/blob/main/.github/workflows/platformDeploy.yml) is what actually runs the deployment on all three platforms (iOS, Android, Web). It runs a staging deploy whenever a new tag is pushed to GitHub, and runs a production deploy whenever a new release is created. +The [`deploy` workflow](https://github.com/Expensify/App/blob/main/.github/workflows/deploy.yml) runs when code is pushed to the `staging` or `production` branches. It creates a tag matching the new version, runs the deployment on all three platforms (iOS, Android, Web), creates or updates the `StagingDeployCash` checklist (staging only), and creates a GitHub Release for the new version. ### lockDeploys -The [`lockDeploys` workflow](https://github.com/Expensify/App/blob/main/.github/workflows/lockDeploys.yml) executes when the `StagingDeployCash` is locked, and it waits for any currently running staging deploys to finish, then gives Applause the :green_circle: to begin QA by commenting in the `StagingDeployCash` checklist. +The [`lockDeploys` workflow](https://github.com/Expensify/App/blob/main/.github/workflows/lockDeploys.yml) executes when a person re-adds the lock label to the `StagingDeployCash` after removing it (it does not run for the label OSBotify adds at creation), and it waits for any currently running staging deploys to finish, then gives Applause the :green_circle: to begin QA by commenting in the `StagingDeployCash` checklist. ### finishReleaseCycle -The [`finishReleaseCycle` workflow](https://github.com/Expensify/App/blob/main/.github/workflows/finishReleaseCycle.yml) executes when the `StagingDeployCash` is closed. It updates the `production` branch from `staging` (triggering a production deploy), deploys `main` to staging (with a new `PATCH` version), and creates a new `StagingDeployCash` deploy checklist. +The [`finishReleaseCycle` workflow](https://github.com/Expensify/App/blob/main/.github/workflows/finishReleaseCycle.yml) executes when the `StagingDeployCash` is closed. It updates the `production` branch from `staging` (triggering a production deploy), deploys `main` to staging (with a new `PATCH` version), which creates a new `StagingDeployCash` deploy checklist. ### testBuild The [`testBuild` workflow](https://github.com/Expensify/App/blob/main/.github/workflows/testBuild.yml) builds ad-hoc staging apps (hybrid iOS, hybrid Android, and web) from pull requests submitted to the App and Mobile-Expensify repositories. This process enables testers to review modifications before they are merged into the main branch and deployed to the staging environment. This workflow accepts up to two inputs: -- A PR number from the App repository for testing New Dot (ND) changes. -- A PR number from the Mobile-Expensify repository for testing Old Dot (OD) changes. +- A PR URL from the App repository for testing New Dot (ND) changes. +- A PR URL from the Mobile-Expensify repository for testing Old Dot (OD) changes. -Both PR numbers can be entered simultaneously if the changes from both repositories need to be combined and tested. Additionally, contributors can explicitly link related PRs from the Mobile-Expensify repository in the App repository PR description if required. Guidance on linking PRs can be found [in PR template](https://github.com/Expensify/App/blob/main/.github/PULL_REQUEST_TEMPLATE.md?plain=1#L25-L30) +Both PR URLs can be entered simultaneously if the changes from both repositories need to be combined and tested. Additionally, contributors can explicitly link related PRs from the Mobile-Expensify repository in the App repository PR description if required. Guidance on linking PRs can be found [in PR template](https://github.com/Expensify/App/blob/main/.github/PULL_REQUEST_TEMPLATE.md?plain=1#L25-L30) ## Local Production Builds diff --git a/cspell.json b/cspell.json index 0e195ee10cf9..232fb5466088 100644 --- a/cspell.json +++ b/cspell.json @@ -164,6 +164,7 @@ "FinancialForce", "Fiscale", "Flamegraph", + "Forcepoint", "Français", "Frederico", "Fábio", @@ -431,6 +432,7 @@ "Unassigning", "Uncapitalize", "Undelete", + "Undeletes", "Unlaminated", "Unmigrated", "Unsharing", @@ -450,6 +452,7 @@ "Woohoo", "Wooo", "Wordmark", + "XHRAPI", "XNOR", "XYWH", "Xfermode", @@ -720,6 +723,7 @@ "illion", "imagebutton", "inbetweenCompo", + "incentivized", "initialises", "inputmethod", "insperity", @@ -1083,6 +1087,7 @@ "unassigns", "uncategorized", "unconfigured", + "undeleting", "unflushed", "unheld", "unhold", @@ -1095,6 +1100,7 @@ "unredacted", "unregisters", "unreimbursed", + "unreporting", "unscrollable", "unsharing", "unsubmits", diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index b1b195dcbaa4..1cd4f06c6d84 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -1,8 +1,8 @@ GEM remote: https://rubygems.org/ specs: - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) base64 (0.3.0) bigdecimal (3.1.8) colorator (1.1.0) diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index fe786b23176f..274ca75cdcef 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -3,7 +3,7 @@ - Expensify Help + ExpensifyHelp diff --git a/docs/articles/expensify-classic/connections/quickbooks-online/Troubleshooting/Export-Errors/ONL583-Export-Error.md b/docs/articles/expensify-classic/connections/quickbooks-online/Troubleshooting/Export-Errors/ONL583-Export-Error.md index 093cc78f3deb..7d9a26e860be 100644 --- a/docs/articles/expensify-classic/connections/quickbooks-online/Troubleshooting/Export-Errors/ONL583-Export-Error.md +++ b/docs/articles/expensify-classic/connections/quickbooks-online/Troubleshooting/Export-Errors/ONL583-Export-Error.md @@ -1,78 +1,41 @@ --- title: ONL583 Export Error in QuickBooks Online Integration -description: Learn what the ONL583 export error means in QuickBooks Online and how to resolve vendor record matching conflicts. -keywords: ONL583, QuickBooks Online export error, vendor already exists QuickBooks, vendor could not be matched QuickBooks, duplicate vendor record QuickBooks, Expensify QuickBooks Online integration, Workspace Admin -internalScope: Audience is Workspace Admins using the QuickBooks Online integration. Covers resolving the ONL583 export error caused by duplicate or mismatched vendor records. Does not cover other QuickBooks Online error codes. +description: Learn how to fix the ONL583 export error when a vendor, supplier, customer, or employee name in QuickBooks Online prevents Expensify from creating a vendor for the report submitter. +keywords: ONL583, QuickBooks Online duplicate name, vendor already exists, supplier name conflict, customer or employee name conflict, submitter email mismatch, Expensify QuickBooks Online integration, Workspace Admin +internalScope: Audience is Workspace Admins using the QuickBooks Online integration. Covers fixing the ONL583 export error caused by a name conflict when automatically creating a vendor for the report submitter. Does not cover other QuickBooks Online error codes. --- # ONL583 Export Error in QuickBooks Online Integration -If you see the error: +The ONL583 error identifies the submitter's email and the conflicting record name. For example: -ONL583: A vendor with this name already exists but could not be matched. +> ONL583 Export Error: We could not find a vendor/supplier in QuickBooks Online for jane@acme.com. A record named 'Jane Doe' already exists in QuickBooks Online under a different email, or as another record type such as a customer or employee. Please rename that record, or create a vendor/supplier for jane@acme.com under a different name. -This means Expensify attempted to create or match a Vendor record for the report submitter, but QuickBooks Online could not confidently match it to an existing vendor, preventing the export from completing. +This means Expensify cannot find a matching vendor/supplier by email, and QuickBooks Online rejects creating one because its name is already in use. --- ## Why the ONL583 Export Error Happens in QuickBooks Online -The ONL583 error typically indicates: +The ONL583 error occurs when: -- A Vendor record already exists in QuickBooks Online. -- The email address on the Vendor record does not exactly match the email address used in Expensify. -- The same email address appears on multiple records (such as both an Employee and a Vendor). -- QuickBooks cannot determine which record to use. +- Expensify cannot find a vendor/supplier matching the report submitter's email address. +- Expensify attempts to create a vendor for the submitter. +- The name is already used by a vendor/supplier with a different email, or by a customer or employee record. -QuickBooks requires a clear one-to-one match between the report submitter and a single Vendor record. - -This is a QuickBooks Online vendor matching issue, not a Workspace configuration issue. +Expensify matches vendors by email address. QuickBooks Online requires unique names across vendors/suppliers, customers, and employees. --- -## How to Fix the ONL583 Export Error - -This issue can be resolved by reviewing and correcting Vendor records in QuickBooks Online. - -### Confirm the Vendor Email Matches Expensify +## How to Fix the Name Conflict in QuickBooks Online 1. Log in to QuickBooks Online. -2. Open the Vendor record for the report creator or submitter. -3. Confirm the email address exactly matches the email listed in Expensify. -4. Save any changes. - -The email must match exactly for Expensify to link the records correctly. - ---- - -### Check for Duplicate Records - -1. Use the QuickBooks Online search function. -2. Search for the report submitter’s email address. -3. Confirm the email is not listed on multiple records (such as both an Employee and Vendor). - -If duplicates exist: - -- Remove the email from the incorrect record, or -- Deactivate duplicate records if appropriate. - -Only one active Vendor record should contain the matching email. - ---- - -### Disable Automatic Entity Creation (If Needed) - -If matching issues continue: - -1. Go to **Settings > Workspaces**. -2. Select your Workspace. -3. Click **Accounting**. -4. Click **Configure**. -5. Open the **Advanced** tab. -6. Disable **Automatically Create Employees/Vendors**. -7. Click **Save**. - -Then retry exporting the report. +2. Search for the **record name shown in the error**, not just the submitter's email. Check vendor/supplier, customer, and employee records. +3. If the record is the correct vendor/supplier for the submitter, update its email to exactly match the submitter's email in Expensify and save your changes. +4. If the record belongs to someone else or is a customer or employee, choose one of these fixes: + - Rename the conflicting record so Expensify can automatically create the vendor/supplier under the original name. + - Create a vendor/supplier with a different, unique name and the submitter's exact email address. +5. In Expensify, select **Sync Now** for the QuickBooks Online connection. Once the sync completes, retry exporting the report. --- @@ -80,12 +43,16 @@ Then retry exporting the report. ## Can I Retry the Export? -Yes. After correcting Vendor records and selecting **Sync Now**, retry the export. +Yes. After correcting the records in QuickBooks Online, select **Sync Now** in Expensify and retry exporting the report. ## Does ONL583 Mean the Vendor Does Not Exist? -Not necessarily. It usually means the Vendor exists but could not be matched due to email differences or duplicate records. +Not necessarily. A vendor/supplier may exist under a different email, or the name may belong to a customer or employee. Expensify cannot find a vendor/supplier matching the submitter's email and cannot create one with the conflicting name. + +## Can I Fix This by Disabling Automatic Vendor Creation? + +Disabling automatic creation does not resolve the name conflict or create a matching vendor/supplier. If you manage vendors manually, create a vendor/supplier with a unique name and the submitter's exact email, then select **Sync Now** in Expensify and retry the export. ## Do I Need to Reconnect QuickBooks Online? -No. Correcting Vendor records and retrying the export is typically sufficient. +No. Resolve the name conflict or correct the vendor/supplier email, then select **Sync Now** in Expensify and retry the export. diff --git a/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md b/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md index aa9a4a85ca61..903469427f77 100644 --- a/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md +++ b/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md @@ -173,6 +173,18 @@ To accomplish this, include phrases like: --- +## How to write instructions that delete expense reports + +**Goal:** Deletes an unsubmitted expense report the requester owns, after they confirm the deletion. Only reports that are still open or in draft can be deleted — a report that has been submitted, approved, reimbursed, or closed cannot be. Deleting an individual expense rather than a whole report is covered by the edit-expenses capability. + +To accomplish this, include phrases like: +- Delete this report +- Remove the report I just created +- Cancel this expense report +- Delete the report named Q1 Travel + +--- + ## How to write instructions that create expenses **Goal:** Creates a new expense — a cash or receipt expense, or a distance/mileage entry — for the person who asked, using the details in their message. @@ -215,6 +227,20 @@ To accomplish this, include phrases like: --- +## How to write instructions that send direct messages to workspace members + +**Goal:** Opens a 1:1 direct message with any workspace member and sends a message from the agent, creating the DM thread if one does not already exist. + +To accomplish this, include phrases like: +- Send each employee a reminder to submit their expenses at the end of the month +- Message the submitter directly when their report is rejected +- Notify individual workspace members of a workspace change +- DM each person with outstanding reports a personalized nudge +- Send a private message to the submitter when a receipt is missing +- Reach out to each team member individually with their spending summary + +--- + ## How to write instructions that look up expense data **Goal:** Runs a read-only lookup across expense history to retrieve spend totals, rankings, period comparisons, or per-submitter figures the rule needs to make a decision. @@ -269,4 +295,4 @@ To accomplish this, include phrases like: --- -*This reference covers all 20 AI Agent capabilities.* +*This reference covers all 22 AI Agent capabilities.* diff --git a/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md b/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md index 08da0392b128..729426ee14f8 100644 --- a/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md +++ b/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md @@ -102,20 +102,27 @@ To react to this event, include phrases like: - When someone changes what a report is called - When someone updates a report title -**An expense report was marked as done.** +**An expense report was marked as done, or a support ticket was resolved.** To react to this event, include phrases like: - When an expense report is marked as done - When an invoice is closed +- When a support ticket is resolved - When someone closes a report -**A previously closed or approved expense report was reopened, returning it to an open (draft/editable) state so the submitter can revise it.** +**A previously closed or approved expense report was reopened, returning it to an open (draft/editable) state so the submitter can revise it. A resolved support ticket was reopened for further help.** To react to this event, include phrases like: - When a closed report is reopened +- When a support ticket is reopened - When an approved report is sent back for editing - When a report is moved back to draft state +**A satisfaction survey was added to a resolved support ticket.** + +To react to this event, include phrases like: +- When a customer is asked to rate their support experience + **The current approver manually assigned the expense report to themselves, taking ownership of it from whoever it was previously assigned to.** To react to this event, include phrases like: @@ -258,6 +265,12 @@ To react to this event, include phrases like: - When a transaction is permanently removed - When someone deletes an expense from a report +**A previously deleted expense was restored.** + +To react to this event, include phrases like: +- When a deleted expense is restored +- When someone undeletes an expense + **An expense was removed from all expense reports and returned to "Unreported" status.** To react to this event, include phrases like: @@ -711,6 +724,12 @@ To react to this event, include phrases like: - When an employee's workspace role or approver is changed - When a member's settings are updated in the workspace +**A workspace admin set the display name of a member who did not have one.** + +To react to this event, include phrases like: +- When an admin names a member who signed up with a phone number +- When a member gets a display name for the first time + **A workspace admin changed whether users with matching email domains automatically join the workspace.** To react to this event, include phrases like: @@ -899,4 +918,4 @@ To react to this event, include phrases like: --- -*This reference covers all 123 events AI Agents can react to.* +*This reference covers all 126 events AI Agents can react to.* diff --git a/docs/articles/new-expensify/ai-agents/Use-Agent-Templates.md b/docs/articles/new-expensify/ai-agents/Use-Agent-Templates.md index 560e243d999e..c77f6fa47b00 100644 --- a/docs/articles/new-expensify/ai-agents/Use-Agent-Templates.md +++ b/docs/articles/new-expensify/ai-agents/Use-Agent-Templates.md @@ -1,68 +1,73 @@ --- title: Use Agent Templates -description: Learn what each built-in Agent template does and when to use it to automate common workflows. -keywords: [Agent templates, AI agents, TipMaster, Translator Tess, Splitter Sam, starter agents, AI workflows] -internalScope: Audience is members creating personal Agents. Covers the available built-in Agent templates, how they work, and when to use each one. Does not cover creating or managing Agents, Agent rules, or writing custom Agent instructions. -retrievalIntent: Which Agent template should I use? +description: Learn what each built-in agent template does, how it works, and when to use it. +keywords: [agent templates, agents, TipMaster, Translator Tess, Splitter Sam, Description Dan, AI agent, receipt translator, card splitter, automate description] +internalScope: Audience is New Expensify members creating AI agents. Covers the built-in agent templates available when creating a new agent and what each one does. Does not cover creating custom agents, editing agent instructions, Agent rules, or RuleBot. contentType: topic +platform: New Expensify --- # Use Agent Templates -Agent templates are prebuilt personal Agents with instructions already written for common tasks. Instead of creating an Agent from scratch, you can start with a template and customize its instructions before creating the Agent. +Agent templates are prebuilt personal Agents with instructions already written for common tasks. Instead of creating an agent from scratch, you can start with a template and customize its instructions at any time. -To learn how to create an Agent, see [Create and Manage Agents](/articles/new-expensify/ai-agents/Create-and-Use-Custom-Agents). +Expensify includes a growing library of built-in templates, each designed for a specific workflow. This article explains what each template does and when you might want to use it. --- -## How Agent templates work +## How agent templates work -Each Agent template includes a predefined set of instructions designed for a specific workflow. +Every template is a standard Expensify agent with instructions that are already written for you. Like any agent, it runs from its own Expensify account and is added as a full-access Copilot on your account, allowing it to review expenses and act on your behalf. -When you create an Agent from a template, you can review and edit those instructions before saving the Agent. After it's created, you can continue updating its instructions at any time. +Each template monitors your expenses and receipts for specific conditions. When those conditions are met, the agent performs its configured action, such as adding a comment or moving an expense to another report. If an expense doesn't match the template's instructions, the agent takes no action. -For guidance on writing clear, effective instructions, see [How to Write Agent Instructions](/articles/new-expensify/ai-agents/How-to-Write-Agent-Instructions). +When you create an agent from a template, you can edit its instructions at any time to customize its behavior. + +![Agents page showing available templates]({{site.url}}/assets/images/Agent_Templates.png){:width="100%"} --- ## What TipMaster does -TipMaster identifies restaurant receipts where the tip exceeds 20% of the pre-tax subtotal and adds a comment showing the subtotal, tip amount, and calculated tip percentage. +TipMaster flags restaurant receipts where the tip exceeds 20%. + +When you add a restaurant receipt, TipMaster calculates the tip percentage using the pre-tax subtotal. If the tip is greater than 20%, it adds a comment showing the subtotal, tip amount, and calculated percentage. It ignores receipts that aren't from restaurants and tips that are 20% or less. -TipMaster is useful for monitoring meal spending, identifying accidental over-tipping, or highlighting expenses that may need additional review. +TipMaster is useful for maintaining consistent meal spending, identifying accidental over-tipping, or highlighting expenses that may need additional review. --- ## What Translator Tess does -Translator Tess translates receipts that aren't written in English and comments with an English translation of the receipt's key details, including the merchant, date, line items, subtotal, tax, and total. +Translator Tess translates receipts that aren't written in English. + +When you add a receipt in another language, Translator Tess comments with an English translation of the receipt's key details, including the merchant, date, line items, subtotal, tax, and total. Receipts that are already in English are ignored. Translator Tess is useful for international travel, reviewing foreign-language receipts, or keeping expense documentation consistent across global teams. --- -## What Splitter Sam does +## What HotelSplitBot does + +HotelSplitBot itemizes hotel expenses so each charge can have its own category. -Splitter Sam moves company card expenses to the correct card report, creating the report if necessary. Cash expenses aren't affected. +When a hotel receipt includes separate charges, such as the room, meals, parking, or laundry, HotelSplitBot splits them into individual expenses while keeping the room charge as its own expense. The split amounts add up to the original expense total. Hotel expenses that only include a room charge or have already been split aren't changed. -Splitter Sam is useful if you use multiple company cards and want each card's expenses grouped on its own report for easier reconciliation. +HotelSplitBot is useful for itemizing hotel stays and categorizing room charges and incidentals separately. --- -## What happens after you create an Agent from a template +## What Description Dan Does -Creating an Agent from a template creates a standard personal Agent with the template's instructions pre-filled. +Description Dan automatically adds descriptions to expenses based on descriptions you've previously used for the same merchant. -After the Agent is created, you can: +When an expense has no description, Description Dan looks at your past expenses from that merchant. If the same description has been used repeatedly, it adds that description to the new expense. Expenses that already have a manually entered description aren't changed. - - Edit its instructions at any time. - - Chat with the Agent. - - Copilot into the Agent's account. - - Use the Agent the same way you would any other personal Agent. +Description Dan is useful for recurring purchases where you typically use the same description, helping keep expense details consistent without entering them manually each time. --- ## Related articles - - [Understand How AI Agents Work in Expensify](/articles/new-expensify/ai-agents/Understand-How-AI-Agents-Work-in-Expensify) - - [Create and Use Agents](/articles/new-expensify/ai-agents/Create-and-Use-Agents) +- [Create and use agents](/articles/new-expensify/ai-agents/Create-and-Use-Custom-Agents) +- [Write agent rules](/articles/new-expensify/ai-agents/How-to-Write-Agent-Rules) diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Import-Personal-Card-Transactions-From-a-Spreadsheet.md b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Import-Personal-Card-Transactions-From-a-Spreadsheet.md index 658073248542..6b4452db5d17 100644 --- a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Import-Personal-Card-Transactions-From-a-Spreadsheet.md +++ b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Import-Personal-Card-Transactions-From-a-Spreadsheet.md @@ -1,8 +1,8 @@ --- title: Import Personal Card Transactions From a Spreadsheet -description: Learn how members can manually import personal card transactions using a spreadsheet or bank statement file in Wallet. -keywords: [New Expensify, import personal card, upload file, import spreadsheet, CSV, TXT, XLS, XLSX, OFX, QFX, bank statement, Wallet, card feed, reimbursable, bring your own card, BYOC, csv import, import csv, upload csv, spreadsheet import, import transactions, csv file, excel import, xls import, ofx import, qfx import, upload bank statement] -internalScope: Audience is all members. Covers how to import, update, and delete personal card transactions from a spreadsheet or an .ofx/.qfx bank statement file. Does not cover company cards or Plaid connections. +description: Learn how members can manually import personal card transactions, including categories and tags, using a spreadsheet or bank statement file in Wallet. +keywords: [New Expensify, import personal card, upload file, import spreadsheet, CSV, TXT, XLS, XLSX, OFX, QFX, bank statement, Wallet, card feed, reimbursable, bring your own card, BYOC, csv import, import csv, upload csv, spreadsheet import, import transactions, csv file, excel import, xls import, ofx import, qfx import, upload bank statement, import tags, tag column, import categories, category column] +internalScope: Audience is all members. Covers how to import, update, and delete personal card transactions from a spreadsheet or an .ofx/.qfx bank statement file, including mapping optional Category and Tag columns and the 255-character tag limit. Does not cover company cards or Plaid connections. order: 3 --- @@ -27,7 +27,8 @@ Anyone can import personal card transactions using a spreadsheet or bank stateme 3. Enter a **Card display name** and configure the currency, reimbursable state, and amount sign direction, then click **Next**. 4. Click **Choose file** and select your spreadsheet, or drag and drop it onto the upload area. 5. Map your spreadsheet columns to the required fields (**Date**, **Merchant**, **Amount**). -6. Click **Import**. +6. (Optional) Map columns to **Category** or **Tag** to add a category or tag to each imported expense. +7. Click **Import**. -Nothing is imported until you choose which candidates to import and click **Save**. +After you connect, you'll see **Your connection is syncing**. The first connection can take some time. Nothing is imported until you choose which candidates to import and click **Save**. --- @@ -53,7 +59,7 @@ Nothing is imported until you choose which candidates to import and click **Save The **Import settings** page controls which Greenhouse candidates are imported into the workspace. You must use **Job stages**, **Tags**, or both. **Offices** is optional and narrows the import further. -When more than one filter is on, a candidate must match all of them to be imported. Within a single filter, a candidate only needs to match one of the values you select. For example, if you select two tags, a candidate with either tag matches and will be invited. +When more than one filter is on, a candidate must match all of them to be imported. Within a single filter, a candidate only needs to match one of the values you select. For example, if you select two tags, a candidate with either tag is imported. 1. On the **Recruiting** page, click **Import settings**. 2. Turn on **Job stages**, **Tags**, or both: @@ -63,17 +69,25 @@ When more than one filter is on, a candidate must match all of them to be import 4. By default, turning on a filter selects every value. To choose specific values, click **Job stage**, **Tag**, or **Office**, select the values you want, and click **Save**. 5. Click **Save**. + + If you click **Save** without turning on **Job stages** or **Tags**, you'll see **Enable Job stages or Tags to continue**. Turning on **Offices** by itself isn't enough. -Saving your import settings starts a sync. After that, Expensify checks Greenhouse for new matching candidates about once a day. If a candidate has more than one application in Greenhouse, they're imported as long as any one of their applications matches your filters. Expensify doesn't remove candidates automatically, even after they move to a different stage or have their tag removed in Greenhouse. +Saving your import settings starts a sync. If a candidate has more than one application in Greenhouse, they're imported as long as any one of their applications matches your filters. Candidates without an email address in Greenhouse are skipped. + +--- -**Example: Using Job stages and Tags to filter candidate imports** +## How to control when Greenhouse candidates get an Expensify invite -You can combine a job stage and a tag so candidates don't get an Expensify invite before they hear from you. +Combine a job stage and a tag so candidates don't get an Expensify invite before they hear from you. 1. In Greenhouse, create a tag called **Ready for Expensify**. -2. In Expensify, turn on **Job stages** and select **Onsite Interview**. -3. Turn on **Tags** and select **Ready for Expensify**. +2. In Expensify, on the **Import settings** page, turn on **Job stages** and select **Onsite Interview**. +3. Turn on **Tags**, select **Ready for Expensify**, and click **Save**. Then, for each candidate: @@ -81,13 +95,15 @@ Then, for each candidate: 2. After you've emailed them, add the **Ready for Expensify** tag to the candidate in Greenhouse. 3. On the next sync, Expensify imports the candidate and sends their invite. -Because candidates must match both the stage and the tag, no one is invited until you've tagged them. +Because candidates must match both the stage and the tag, no one is invited until you've tagged them. + +--- -**Example: Using Job stages, Tags and Offices to filter candidate imports** +## How to import Greenhouse candidates into different workspaces by office -If you use separate workspaces for different entities, you can use **Offices** to send each candidate to the workspace that exports to the right entity. +If you use separate workspaces for different entities, use **Offices** to send each candidate to the workspace that exports to the right entity. -1. Set up the same job stage and tag filters from the example above in each workspace. +1. In each workspace, set up the same **Job stages** and **Tags** filters. 2. In the workspace that exports to your UK entity, turn on **Offices** and select **London**. 3. In the workspace that exports to your US entity, turn on **Offices** and select **New York**. @@ -95,22 +111,19 @@ London candidates are imported only into the UK workspace, and New York candidat --- -## What happens after you connect Greenhouse to Expensify +## What happens after Greenhouse syncs candidates to Expensify -- If you're on the **Recruiting** or **Members** page when a sync finishes, the **Greenhouse sync complete** screen opens with the **Added**, **Removed**, and **Skipped** candidate counts. Click **Skipped** to see each skipped candidate and the reason they were skipped, then click **Got it** to close the screen. This screen doesn't open for syncs that finish in the background, such as the daily automatic sync, or if you leave the page before the sync finishes. -- The Greenhouse connection displays the **Last synced** timestamp. - Candidates who match your import settings are added to the workspace and appear on the **Members** page. - Candidates who don't already have an Expensify account receive an email invitation to finish setting up their account. -- You can configure the **Default approver** for candidates from the **Recruiting** page. - -Expensify also runs a daily auto-sync to keep candidates up to date: +- The Greenhouse connection displays the **Last synced** timestamp. +- Expensify checks Greenhouse about once a day for new matching candidates and updates candidate details to match Greenhouse. +- Expensify doesn't remove candidates automatically, even after they move to a different stage or have their tag removed in Greenhouse. -- New candidates who match your import settings are added to the workspace. -- Candidate details are updated to match Greenhouse. +If you're on the **Recruiting** or **Members** page when a sync finishes, the **Greenhouse sync complete** screen shows the **Added** and **Skipped** candidate counts. Click **Skipped** to see why each candidate was skipped. This screen doesn't appear for syncs that finish in the background, such as the daily sync, or if you leave the page before the sync finishes. --- -## How to configure the approval mode for Greenhouse +## How to set the default approver for Greenhouse candidates The **Default approver** setting controls who approves expenses for candidates imported from Greenhouse. @@ -123,7 +136,7 @@ The **Default approver** setting controls who approves expenses for candidates i --- -## What happens to approval workflows after connecting Greenhouse +## What happens to approval workflows when you set a Greenhouse approval mode When **Basic approval** or **Advanced approval** is selected: @@ -131,10 +144,7 @@ When **Basic approval** or **Advanced approval** is selected: - You can't edit approval workflows on the **Workflows** page. - To change approvers, update the **Default approver** setting on the **Recruiting** page. -When **Custom approval** is selected: - -- Approval workflows remain editable in **Workflows**. -- You can manage approval routing manually in Expensify. +When **Custom approval** is selected, you manage approval workflows manually on the **Workflows** page. Learn more about [how to add approvals to a workspace](/articles/new-expensify/workspaces/Add-Approvals). @@ -142,16 +152,12 @@ Learn more about [how to add approvals to a workspace](/articles/new-expensify/w ## How to manually refresh the Greenhouse sync -After connecting Greenhouse, a daily auto-sync runs to keep your workspace candidates up to date with Greenhouse. You can also refresh the sync manually. - -To refresh the sync manually: - 1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces**. 2. Click the name of the workspace connected to Greenhouse. 3. In the left menu, select **Recruiting**. 4. Next to **Greenhouse**, select the three dots **(⋮)**, then click **Sync now**. -Manual syncs are limited to two in 24 hours. If you've reached the limit, you'll see **Try again tomorrow**. The daily auto-sync still runs as usual. +Manual syncs are limited to two in 24 hours. If you've reached the limit, you'll see **Try again tomorrow**. The daily sync still runs as usual. --- @@ -171,7 +177,7 @@ Disconnecting Greenhouse stops future candidate syncs. ## Why is Greenhouse connected but no candidates were imported? -Connecting Greenhouse authorizes the integration. To start importing candidates, click **Complete setup** on **Greenhouse**, turn on **Job stages** or **Tags** on the **Import settings** page, and click **Save**. If the sync finishes but no candidates were added, check that your import settings match candidates in Greenhouse. +Connecting Greenhouse only authorizes the integration. To start importing candidates, click **Complete setup** under **Greenhouse**, turn on **Job stages** or **Tags** on the **Import settings** page, and click **Save**. If the sync finishes but no candidates were added, check that your import settings match candidates in Greenhouse. ## Why wasn't a candidate imported from Greenhouse? @@ -189,6 +195,6 @@ We recommend a separate workspace for candidates. This lets you set up categorie You must disconnect Greenhouse before you can turn off **Recruiting** under **More features**. -## I don't see my ATS on the Recruiting page. What should I do? +## What if I don't see my ATS on the Recruiting page? To request another ATS integration, click **Ask Concierge** on the **Recruiting** page. diff --git a/docs/articles/new-expensify/connections/connect-ai-assistants/Connect-ChatGPT-to-Expensify-Using-MCP.md b/docs/articles/new-expensify/connections/connect-ai-assistants/Connect-ChatGPT-to-Expensify-Using-MCP.md index 2958d4488c4c..b95db84b976a 100644 --- a/docs/articles/new-expensify/connections/connect-ai-assistants/Connect-ChatGPT-to-Expensify-Using-MCP.md +++ b/docs/articles/new-expensify/connections/connect-ai-assistants/Connect-ChatGPT-to-Expensify-Using-MCP.md @@ -16,7 +16,7 @@ Learn more about supported AI clients, permissions, and security in [Use the Exp ## Who can connect ChatGPT to Expensify using MCP -Any member with an Expensify account and access to ChatGPT can connect using the Expensify connector in the ChatGPT connector directory. +Any member with an Expensify account and access to ChatGPT can connect using the Expensify plugin in the ChatGPT plugin directory. Requirements: @@ -29,22 +29,19 @@ Requirements: ## How to connect Expensify in ChatGPT 1. Open ChatGPT. -2. Go to **Settings**. -3. Select **Plugins**. -4. Select **Expensify** from the list. -5. Click **Connect**. -6. Complete the OAuth sign-in and approval flow in your browser. -7. Click **Allow**. +2. Click **Plugins**. +3. Search **Expensify**. +4. Click the **+** to connect. +5. Click **Allow**. --- -## How to use the Expensify connector in ChatGPT conversations +## How to use the Expensify plugin in ChatGPT conversations 1. Open a new chat in ChatGPT. 2. Click the **+** button near the message composer. -3. Select **More**. -4. Select the Expensify connector. -5. Ask a question about your Expensify data. +3. Select the **Expensify** plugin. +4. Ask a question about your Expensify data. --- @@ -76,9 +73,9 @@ Make sure you completed the OAuth approval flow in your browser. If ChatGPT still cannot access your data: -- Go to **Settings** > **Plugins** in ChatGPT and confirm that **Expensify** appears under **Enabled Plugins**. -- Open the **Expensify** connector in ChatGPT, select the three dots **(⋮)**, and click **Reconnect**. -- Remove the Expensify connector and add it again by following the setup instructions in this article. +- Go to **Plugins** in ChatGPT and confirm that **Expensify** appears under **Installed**. +- Open the **Expensify** plugin in ChatGPT, select the three dots **(⋮)**, and click **Reconnect**. +- Remove the Expensify plugin and add it again by following the setup instructions in this article. ## Can ChatGPT edit expenses or approve reports? diff --git a/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md b/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md index 07821604e83a..ca1fd7f15564 100644 --- a/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md +++ b/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md @@ -70,6 +70,8 @@ An individual card uses its per-card account when one is configured. Otherwise, You can also change an individual company card's DualEntry export account from its card details. +To review or change the vendor on an individual expense before it exports, learn how to [manage vendor matching for DualEntry](/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry). + --- ## How to configure DualEntry advanced settings diff --git a/docs/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry.md b/docs/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry.md new file mode 100644 index 000000000000..b63936276903 --- /dev/null +++ b/docs/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry.md @@ -0,0 +1,116 @@ +--- +title: Manage Vendor Matching for DualEntry +description: Learn how DualEntry vendor matching assigns vendors to non-reimbursable company card expenses before they export, including automatic matching, manual selection, subsidiary scoping, and default vendor behavior. +keywords: [DualEntry, vendor matching, vendor, company card expenses, default vendor for all company cards, Credit Card Misc, company card export] +internalScope: Audience is workspace admins using the DualEntry connection for company card exports. Covers imported DualEntry vendors, subsidiary scoping, automatic and manual vendor assignment, default vendor behavior, the Credit Card Misc export fallback, and vendors that are no longer valid. Does not cover DualEntry connection setup, other DualEntry configuration settings, or vendor matching for other accounting connections. +order: 3 +--- + +# Manage Vendor Matching for DualEntry + +DualEntry vendor matching lets workspace admins review and update the vendor assigned to non-reimbursable company card expenses before they export to DualEntry. Expensify imports your DualEntry vendor list, automatically matches vendors where possible, and lets admins set or update the **Vendor** field before export. This helps ensure expenses export with the correct vendor instead of requiring manual corrections in DualEntry. + +## Who can use DualEntry vendor matching + +This feature is available to workspace admins whose workspace: + + - Is connected to DualEntry. + - Has finished configuring the DualEntry connection. + +If your workspace isn't connected to DualEntry yet, learn how to [connect to DualEntry](/articles/new-expensify/connections/dualentry/Connect-to-DualEntry). + +## Which DualEntry vendors are available in Expensify + +Expensify only imports the vendors a workspace can actually export to. A DualEntry vendor appears in Expensify when it is: + + - Active in DualEntry. + - Either assigned to the **Subsidiary** selected for the connection, or not assigned to any subsidiary. + +Vendors assigned to a different subsidiary are not available, so changing the **Subsidiary** on the connection changes which vendors you can select. + +The same list is used everywhere vendors appear in Expensify: automatic matching, the **Vendor** field on an expense, workspace merchant rules, and the **Default vendor for all company cards** setting. + +## How vendors are matched to DualEntry company card expenses + +Expensify assigns vendors automatically in the following order: + + - If a workspace merchant rule specifies a vendor, that vendor is assigned. + - Otherwise, Expensify automatically matches the merchant name against your imported DualEntry vendor list. For example, **STARBUCKS #456 DOWNTOWN** matches **Starbucks**. + - If no match is found, the **Vendor** field remains empty until a workspace admin selects one. + +Whenever a vendor is assigned automatically, Concierge posts a system message on the expense indicating whether the vendor was set by a merchant rule or by vendor matching. + +The **Vendor** field appears only on non-reimbursable expenses. It isn't shown on reimbursable expenses or on invoices. + +## How to select a DualEntry vendor on an expense + +1. Open the non-reimbursable expense. +2. Select **Vendor**. +3. Search for the vendor by name. +4. Select the vendor you want to assign. + +Once a vendor is selected manually, Expensify preserves that selection and won't overwrite it with automatic matching. + +![The expense details view for a non-reimbursable company card expense on a DualEntry-connected workspace, with the Vendor row visible directly below Category and a matched vendor name shown]({{site.url}}/assets/images/Expense_select-vendor.png){:width="100%"} + +## Where to find your imported DualEntry vendors + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [workspace name]**. +2. Select **Vendors**. +3. Use **Find vendor** to search the list by name. + +Vendors are managed in DualEntry, so the list is read-only in Expensify and refreshes when the connection syncs. + +## How to set a default vendor for all company cards in DualEntry + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [workspace name]**. +2. Select **Accounting**. +3. On the DualEntry connection, select **Export**. +4. Select **Default vendor for all company cards**. +5. Select a vendor from your imported DualEntry vendor list. + +The default vendor is used only when an expense doesn't already have a vendor assigned. + +## How vendors export to DualEntry + +When company card expenses are exported, Expensify assigns vendors in the following order: + +1. The vendor selected on the expense. +2. The **Default vendor for all company cards** configured in the DualEntry export settings. +3. A vendor named **Credit Card Misc**, if neither of the above is available. + +If no **Credit Card Misc** vendor exists in DualEntry, Expensify creates one on the first export that needs it and reuses it afterward. Expensify does not set it as your **Default vendor for all company cards**, so that setting stays empty until you choose a vendor yourself. + +## What happens when a DualEntry vendor is no longer valid + +If a vendor assigned to an expense is deactivated in DualEntry, or is no longer available to the selected **Subsidiary**, the expense displays a **Vendor no longer valid** error, similar to category, tag, and tax violations. + +Select an active vendor on the expense to clear the error. You can also select **None** to leave the **Vendor** field empty. + +A merchant rule that points at a vendor which is no longer available displays **Vendor unavailable** instead of a vendor name. Edit the rule and choose an available vendor to resolve it. + +--- + +# FAQ + +## Do I have to set a vendor? + +No. The **Vendor** field is optional. + +Expensify automatically attempts to match a vendor using your imported DualEntry vendor list. If no match is found, the field can remain blank, and the expense exports using the **Default vendor for all company cards** configured in your DualEntry export settings, or **Credit Card Misc** if no default is set. + +## Does manually assigning a vendor stop automatic matching? + +Yes. Once a workspace admin manually assigns a vendor to an expense, Expensify preserves that selection and won't replace it with automatic matching. + +## Why don't I see any vendors to choose from? + +The vendor selector shows **No vendors found** when no DualEntry vendors are available to your workspace. This happens when your DualEntry vendor list is empty, when every vendor is inactive, or when every vendor belongs to a different subsidiary than the one selected for the connection. Add or activate the vendors in DualEntry, then sync the connection again. + +## Why did a vendor disappear after I changed the subsidiary? + +Vendors are scoped to the **Subsidiary** selected for the DualEntry connection. After you change the subsidiary, vendors that belong only to the previous subsidiary are no longer available, and expenses or merchant rules using them need an available vendor selected instead. + +## How do I know why Expensify assigned a vendor automatically? + +When Expensify automatically assigns a vendor, Concierge posts a system message on the expense indicating whether the vendor was assigned by a merchant rule or by vendor matching. diff --git a/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md b/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md index 0f6e0d05e5ba..c86a302ed2bd 100644 --- a/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md +++ b/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md @@ -1,7 +1,7 @@ --- title: Configure NetSuite description: Learn how to configure the import, export, and advanced settings for Expensify's integration with NetSuite. -keywords: [New Expensify, NetSuite configuration, import settings, export settings, advanced sync, tags, corporate card export] +keywords: [New Expensify, NetSuite configuration, import settings, export settings, advanced sync, tags, corporate card export, Split exports by posting period, Export to next open period] order: 2 --- @@ -75,7 +75,8 @@ To manage how reports are exported from Expensify to NetSuite: - **Export Invoices To** – Choose the Accounts Receivable account - **Invoice Item** – Select or create the item line for invoices - **Export Foreign Currency Amount** – Enable to export original currency values -- **Export to Next Open Period** – Enable to skip closed periods and use the next available NetSuite period +- **Export to next open period** – Enable to skip closed periods and use the next available NetSuite period +- **Split exports by posting period** – Enable to export non-reimbursable expenses as one NetSuite transaction per calendar month, each dated in its own month. **Export to next open period** must be on first. While this toggle is on, **Export to next open period** stays locked on. - **Export Expensify Travel Payable To** – If Expensify Travel's Consolidated Travel Billing feature is enabled, configure where travel card expenses are exported: - **Non-reimbursable journal posting account** – Select the payable account for Expensify Travel expenses @@ -157,6 +158,21 @@ Check the **Use as Field ID** box in NetSuite. This assigns a usable ID to older No. Auto-sync only applies to newly approved reports. Export older reports manually if needed. +## How does Split exports by posting period work in NetSuite? + +When **Split exports by posting period** is on, a report with non-reimbursable expenses from different months exports as a separate NetSuite transaction for each month. Each transaction is dated in its own month. If a month falls in a closed NetSuite period, **Export to next open period** moves that transaction to the next open period. + + +## Why can’t I turn off Export to next open period? + +**Export to next open period** is locked on while **Split exports by posting period** is on. To turn off **Export to next open period**, first turn off **Split exports by posting period**. + + +## Why can’t I turn on Split exports by posting period? + +**Split exports by posting period** is locked until **Export to next open period** is on. Turn on **Export to next open period** first, then turn on **Split exports by posting period**. + + ## How does multi-currency exporting work in NetSuite? - The employee/vendor currency must match the subsidiary diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Troubleshooting/Export-Errors/ONL583-Export-Error.md b/docs/articles/new-expensify/connections/quickbooks-online/Troubleshooting/Export-Errors/ONL583-Export-Error.md index be4c4c815bd2..b4cbee822bf4 100644 --- a/docs/articles/new-expensify/connections/quickbooks-online/Troubleshooting/Export-Errors/ONL583-Export-Error.md +++ b/docs/articles/new-expensify/connections/quickbooks-online/Troubleshooting/Export-Errors/ONL583-Export-Error.md @@ -1,17 +1,17 @@ --- title: ONL583 Export Error in QuickBooks Online -description: Learn how to fix the ONL583 export error in QuickBooks Online when a vendor already exists but cannot be matched to the report submitter. -keywords: ONL583, QuickBooks Online vendor already exists, vendor could not be matched, submitter email mismatch, duplicate vendor record, Automatically create employees vendors, Expensify QuickBooks Online export error, Workspace Admin, accounting export error -internalScope: Audience is Workspace Admins using QuickBooks Online integration. Covers fixing the ONL583 export error caused by vendor email mismatches or duplicate records. Does not cover other export error codes. +description: Learn how to fix the ONL583 export error when a vendor, supplier, customer, or employee name in QuickBooks Online prevents Expensify from creating a vendor for the report submitter. +keywords: ONL583, QuickBooks Online duplicate name, vendor already exists, supplier name conflict, customer or employee name conflict, submitter email mismatch, Expensify QuickBooks Online export error, Workspace Admin +internalScope: Audience is Workspace Admins using the QuickBooks Online integration. Covers fixing the ONL583 export error caused by a name conflict when automatically creating a vendor for the report submitter. Does not cover other export error codes. --- # ONL583 Export Error in QuickBooks Online -If you see the error: +The ONL583 error identifies the submitter's email and the conflicting record name. For example: -ONL583: A vendor with this name already exists in QuickBooks Online, but couldn’t be matched. Please confirm the existing vendor record uses the same email as the submitter. +> We could not find a vendor/supplier in QuickBooks Online for jane@acme.com. A record named 'Jane Doe' already exists in QuickBooks Online under a different email, or as another record type such as a customer or employee. Please rename that record, or create a vendor/supplier for jane@acme.com under a different name. -This means QuickBooks Online already has a vendor with the same name, but Expensify cannot match it to the report submitter based on email address. +This means Expensify cannot find a matching vendor/supplier by email, and QuickBooks Online rejects creating one because its name is already in use. --- @@ -19,67 +19,23 @@ This means QuickBooks Online already has a vendor with the same name, but Expens The ONL583 error occurs when: -- Expensify attempts to create a vendor for the report submitter. -- A vendor with the same name already exists in QuickBooks Online. -- The existing vendor record does not have the same email address as the submitter in Expensify. -- The submitter’s email address is associated with multiple records (for example, both an employee and a vendor). +- Expensify cannot find a vendor/supplier matching the report submitter's email address. +- Expensify attempts to create a vendor for the submitter. +- The name is already used by a vendor/supplier with a different email, or by a customer or employee record. -Expensify matches vendors using email addresses. If the email does not match exactly, the export will fail. +Expensify matches vendors by email address. QuickBooks Online requires unique names across vendors/suppliers, customers, and employees. --- -## How to Confirm the Vendor Email in QuickBooks Online +## How to Fix the Name Conflict in QuickBooks Online 1. Log in to QuickBooks Online. -2. Go to **Expenses**. -3. Select **Vendors**. -4. Search for the vendor with the same name as the report submitter. -5. Open the vendor record. -6. Confirm the **Email** field exactly matches the submitter’s email address in Expensify. -7. Update the email if needed and click **Save**. - -After updating the email, retry exporting the report. - ---- - -## How to Check for Duplicate Records in QuickBooks Online - -If the export still fails: - -1. Use the global search bar in QuickBooks Online. -2. Search for the report submitter’s email address. -3. Review all associated records (vendor, employee, etc.). -4. Identify any duplicate records using the same email. -5. Remove or deactivate duplicate records as appropriate. -6. Save your changes. - -Retry exporting the report after cleaning up duplicate records. - ---- - -## How to Disable Automatically Create Employees/Vendors in Expensify - -If you prefer to manage vendors manually: - -### On Web - -1. Go to the **Workspaces** navigation tab on the left. -2. Select your Workspace. -3. Click **Accounting**. -4. Click **Advanced**. -5. Turn off **Automatically create employees/vendors**. -6. Click **Save**. - -### On Mobile - -1. Tap the **Workspaces** navigation tab on the bottom. -2. Select your Workspace. -3. Tap **Accounting**. -4. Tap **Advanced**. -5. Turn off **Automatically create employees/vendors**. -6. Tap **Save**. - -After disabling automatic creation, retry exporting the report. +2. Search for the **record name shown in the error**, not just the submitter's email. Check vendor/supplier, customer, and employee records. +3. If the record is the correct vendor/supplier for the submitter, update its email to exactly match the submitter's email in Expensify and save your changes. +4. If the record belongs to someone else or is a customer or employee, choose one of these fixes: + - Rename the conflicting record so Expensify can automatically create the vendor/supplier under the original name. + - Create a vendor/supplier with a different, unique name and the submitter's exact email address. +5. In Expensify, select **Sync now** for the QuickBooks Online connection. Once the sync completes, retry exporting the report. --- @@ -87,8 +43,12 @@ After disabling automatic creation, retry exporting the report. ## Does the Vendor Email Have to Match Exactly? -Yes. The vendor email in QuickBooks Online must exactly match the submitter’s email address in Expensify for the export to work. +Yes. The vendor/supplier email in QuickBooks Online must exactly match the submitter's email address in Expensify for Expensify to match the records. + +## Can a Customer or Employee Record Cause This Error? + +Yes. A customer or employee record can use the name Expensify is trying to give the vendor/supplier. Search by the name shown in the error, even if that record uses a different email. -## Can Duplicate Vendor or Employee Records Cause This Error? +## Can I Fix This by Disabling Automatic Vendor Creation? -Yes. If multiple records share the same email address, QuickBooks Online may not correctly match the vendor during export, resulting in the ONL583 error. +Disabling automatic creation does not resolve the name conflict or create a matching vendor/supplier. If you manage vendors manually, create a vendor/supplier with a unique name and the submitter's exact email, then select **Sync now** in Expensify and retry the export. diff --git a/docs/articles/new-expensify/domains/Manage-Domain-Members.md b/docs/articles/new-expensify/domains/Manage-Domain-Members.md index 80a299f5bf0b..b5cb0976cde8 100644 --- a/docs/articles/new-expensify/domains/Manage-Domain-Members.md +++ b/docs/articles/new-expensify/domains/Manage-Domain-Members.md @@ -102,6 +102,18 @@ Domain Admins can move a member from one domain group to another to change which 6. Select the group you want to move the member to. 7. Click **Save**. +## How to assign a Vacation Delegate on behalf of a Domain Member + +Domain Admins can assign or unassign a vacation delegate on behalf of a domain member. + +1. Go to **Workspaces**, then select the **Domains** tab. +2. Select your domain. +3. Click **Domain Members**. +4. Select the member. +5. Click **Vacation delegate**. +6. Select the member you want to assign as the delegate. +7. Click **Confirm**. + --- # FAQ @@ -122,3 +134,7 @@ Yes. Domain Admins don’t need to use a domain email — they can manage the do No. To remove a Domain Member, you must deactivate (close) the account. However, you can always reinvite them later. +## Can I add or remove a vacation delegate for a Domain Member? + +Yes. As a Domain Admin, you can add or remove a Vacation Delegate on behalf of a domain member. This is a great option if the member is already out of office, but forgot to set their own delegate. + diff --git a/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md b/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md index afca646215a0..f6098d22ba53 100644 --- a/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md +++ b/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md @@ -202,6 +202,39 @@ You can also use keyboard shortcuts on web to jump directly to specific items: --- +## How to start an action using Search + +You can start common actions from **Search** instead of using the **➕ Create** button. + +1. Select **Search** (the magnifying glass icon). +2. Type the name of the action you want to start, such as **Create expense**, **Create report**, **Track distance**, **Start chat**, or **Book travel**. You can also type **go** or **go to** before the name, such as **go to book travel**. +3. Select the action to start it. + +Only the actions available to you appear. **Book travel** appears only when Expensify Travel is enabled on your active workspace, and it opens the same booking tool as the **Book travel** option in the **➕ Create** button. + +Learn how to [enable Expensify Travel on a workspace](/articles/travel/company-setup/Enable-Travel-on-a-Workspace). +## How to jump to a domain page using Search + +If you are a Domain Admin, **Search** also suggests the pages of each domain you administer, so you can open them without going through **Workspaces** > **Domains**. + +1. Select **Search** (the magnifying glass icon). +2. Type the name of the page you want to open — **Domain members**, **Domain admins**, **Groups**, or **SAML** — or type the domain name, such as **example.com**. +3. Select the suggestion to open that page for that domain. + +Each domain suggestion shows the domain name on the right of the row, so you can tell the suggestions apart when you administer more than one domain. + +These suggestions only appear for domains you administer. If you are not a Domain Admin of any domain, only **Go to Domains** appears. + +Learn more about [what Domain Admins can manage](/articles/new-expensify/domains/Domain-Admins). + + + +--- + # FAQ ## Where are navigation tabs located in Expensify? @@ -212,6 +245,14 @@ On web, navigation tabs appear on the left. On mobile, navigation tabs appear on Yes. Select **Search** (the magnifying glass icon), then type the name of a top-level destination: **Home**, **Inbox**, **Spend**, **Workspaces**, or **Account**. Select the **Go to [destination]** result to open it. You can also type **go** or **go to** before the name. +## Can I book travel from Search? + +Yes. Select **Search** (the magnifying glass icon), type **Book travel**, then select the **Book travel** result. This opens the same booking tool as the **Book travel** option in the **➕ Create** button. + +## Why don’t I see Book travel in Search? + +**Book travel** appears only when Expensify Travel is enabled on your active workspace. If a different workspace has Expensify Travel enabled, make that workspace active and search again. If no workspace has it enabled, ask a Workspace Admin to [enable Expensify Travel on a workspace](/articles/travel/company-setup/Enable-Travel-on-a-Workspace). + ## Where do I manage workspace settings? Open the **Workspaces** tab from the navigation tabs. diff --git a/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md b/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md index a9abfb89550d..3d85c24b3a9b 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md @@ -1,8 +1,8 @@ --- title: Attach and edit receipts on expenses description: Learn how to properly attach, verify, and troubleshoot receipts on expenses in New Expensify for audits and accounting. -keywords: [New Expensify, attach receipt, verify receipt, missing receipt, expense attachments, audit trail, receipt not showing, crop receipt, rotate receipt, trim receipt, receipt upload failed, receipt upload error, save receipt, delete expense, ereceipt, ereceipts, e-receipt, electronic receipt, digital receipt, auto-generated receipt, add additional receipt, who can add a receipt, add additional receipt icon missing, expand receipt, receipt on approved report, add receipt after approval, admin add receipt approved expense] -internalScope: Audience is Expensify members and Workspace Admins. Covers attaching, replacing, and cropping receipts on expenses, who is allowed to add a receipt versus only view one, and the Workspace Admin exception that allows attaching or replacing a receipt on an Approved report. Does not cover SmartScan configuration or receipt forwarding via email. +keywords: [New Expensify, attach receipt, verify receipt, missing receipt, expense attachments, audit trail, receipt not showing, crop receipt, rotate receipt, trim receipt, receipt upload failed, receipt upload error, save receipt, delete expense, ereceipt, ereceipts, e-receipt, electronic receipt, digital receipt, auto-generated receipt, add additional receipt, who can add a receipt, add additional receipt icon missing, expand receipt, receipt on approved report, add receipt after approval, admin add receipt approved expense, added a receipt, receipt system message, receipt history, who added a receipt] +internalScope: Audience is Expensify members and Workspace Admins. Covers attaching, replacing, and cropping receipts on expenses, the system message posted when a receipt is added, who is allowed to add a receipt versus only view one, and the Workspace Admin exception that allows attaching or replacing a receipt on an Approved report. Does not cover SmartScan configuration or receipt forwarding via email. --- Make sure your receipts are attached correctly to individual expenses for audit and accounting compliance. This guide explains how to attach, verify, and troubleshoot receipts in New Expensify — and when to use report comments for additional documentation. @@ -69,6 +69,14 @@ You can rotate a receipt to correct its orientation. Rotation is available for u --- +## What system message appears when you add a receipt to an expense + +When you attach a receipt to an existing expense, or click **Replace** to swap in a different receipt, Expensify posts an "added a receipt" system message in the expense. The message shows who added the receipt, so the expense keeps an audit trail of receipt changes. Removing a receipt also posts a system message in the expense. + +Cropping or rotating a receipt doesn't post a system message. An expense created with a receipt, such as a SmartScan expense, doesn't get an "added a receipt" message either. + +--- + ## How to Add Additional Receipts to an Expense Each expense has one primary receipt, but you can attach additional receipt images directly on the expense for situations where multiple receipts are needed — for example, the front and back of a paper receipt, or both the credit card slip and the itemized breakdown. @@ -147,6 +155,9 @@ Apart from a Workspace Admin attaching or replacing a receipt, Approved and Paid ## Why isn’t my receipt showing on the expense? It was likely uploaded in the report comments. Receipts must be attached directly to the expense to appear properly. +## Can I see who added a receipt to an expense? +Yes. When someone attaches or replaces a receipt on an existing expense, Expensify posts an "added a receipt" system message in the expense showing who made the change. Cropping or rotating a receipt doesn't post a message. + ## Can I attach one receipt to multiple expenses? No. Each expense must have its own receipt. You can upload the same file more than once if needed. diff --git a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md index e2618428e85c..ec6190b6f080 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md @@ -1,7 +1,7 @@ --- title: Distance Expenses description: Learn how to create a Distance expense using GPS tracking, map-based routes, manual entry, or odometer readings, and how the reimbursement rate is determined in New Expensify. -keywords: [New Expensify, distance expense, mileage, mileage reimbursement, create expense, distance rate, workspace rate, map route, reimbursement rate, manual mileage, manual distance, global create, track distance, GPS, GPS tracking, start GPS, track route, track mileage, mileage tracking, calculate mileage reimbursement, mileage rate, odometer, odometer reading, odometer image, odometer mileage, odometer distance, start reading, end reading, save for later, in-progress odometer, incomplete odometer, edit distance, edit waypoints, manual edit distance, edit stop, trim trip, edit GPS trip, shorten GPS route, GPS slider, total distance, stop GPS trip, stop without moving, resume GPS trip, discard GPS trip, zero distance trip, same start and stop location, no edit icon, can't create expense] +keywords: [New Expensify, distance expense, mileage, mileage reimbursement, create expense, distance rate, workspace rate, map route, reimbursement rate, manual mileage, manual distance, global create, track distance, GPS, GPS tracking, start GPS, track route, track mileage, mileage tracking, calculate mileage reimbursement, mileage rate, odometer, odometer reading, odometer image, odometer mileage, odometer distance, start reading, end reading, save for later, in-progress odometer, incomplete odometer, edit distance, edit waypoints, manual edit distance, edit stop, trim trip, edit GPS trip, shorten GPS route, GPS slider, total distance, stop GPS trip, stop without moving, resume GPS trip, discard GPS trip, zero distance trip, same start and stop location, no edit icon, can't create expense, change distance rate, edit distance rate, move distance expense, move expense to another workspace, rate not valid for this workspace, distance rate violation] internalScope: Audience is all members. Covers creating Distance expenses using GPS tracking, map-based routes, manual entry, and odometer readings, plus how reimbursement rates are applied. Does not cover configuring Workspace distance rates in detail or broader report submission workflows. --- @@ -161,15 +161,21 @@ Yes! You can edit the expense before it is approved. When editing a map-based Di ## Can I update the Distance expense unit or rate? -The distance unit and rate can only be updated by a Workspace Admin on the Workspace. It is not possible to adjust the distance rate or unit at the expense level. +The distance unit is set by a Workspace Admin on the Workspace and can’t be changed at the expense level. + +The rate can be changed on an individual expense. Open the Distance expense, select the **Rate** field, and choose another rate from the Workspace. The distance, amount, and currency are recalculated to match the rate you select. If a Workspace has rates with effective dates and you manually select a rate that doesn't match the expense date, the expense shows a violation indicating the rate doesn't match the selected date. This is informational and does not block submission — selecting the rate that matches the expense date clears the violation. ## What happens if a Distance expense is moved to a different Workspace? -When a Distance expense is moved to another Workspace, it keeps its original unit and rate. +What happens depends on whether you move individual expenses or a whole report. + +**If you move individual expenses to a report on another Workspace:** when the expense’s rate does not exist on the new Workspace, or is disabled there, the expense automatically switches to that Workspace’s default distance rate. The unit, distance, amount, and currency are recalculated to match. + +**If you move a whole report to another Workspace:** the expenses keep their original unit and rate. If a rate isn’t valid in the new Workspace, the expense shows a “Rate not valid for this workspace” violation. -If the rate isn’t valid in the new Workspace, the expense will show a “Rate not valid for this workspace” violation. Selecting a valid rate will update the expense. +Selecting a valid rate on the expense clears the violation and updates the expense. Open the expense and select the **Rate** field to choose a rate from the new Workspace. ## Do I need to keep the mobile app open during GPS tracking? diff --git a/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md b/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md index d6c8a438c80f..8f136fb9d16f 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md +++ b/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md @@ -45,8 +45,8 @@ You can move expenses to a different report, create a new report, or remove them **To move or remove a single expense from a report:** 1. Open the draft report. -2. Click the checkbox next to the expense you want to move. -3. Click the green **1 selected** button > **Move to report**. +2. Select **More**. +3. Select **Move to report**. 4. Choose a destination report, select **Create report** to create a new report, or select **Remove from report** to move the expense to your personal space. **To move or remove all expenses from a report** diff --git a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md index dcc4212e3991..eef91bd6fa0e 100644 --- a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md +++ b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md @@ -37,7 +37,7 @@ When you're ready to take back control of your reports: ## Can my admin assign a vacation delegate for me? -No. Only you can assign your own vacation delegate. +Yes. In New Expensify, domain admins can [assign a vacation delegate on your behalf](/articles/new-expensify/domains/Manage-Domain-Members#how-to-assign-a-vacation-delegate-on-behalf-of-a-domain-member) under Domain member settings. ## Why can't my vacation delegate reimburse reports? diff --git a/docs/articles/new-expensify/settings/Merge-Accounts.md b/docs/articles/new-expensify/settings/Merge-Accounts.md index fe796b5e44d1..892dd87b9042 100644 --- a/docs/articles/new-expensify/settings/Merge-Accounts.md +++ b/docs/articles/new-expensify/settings/Merge-Accounts.md @@ -14,8 +14,8 @@ If you have multiple Expensify accounts, you can merge them into a single login. - **Merging accounts is permanent and cannot be undone.** - You must log in to the **company account** and merge the **personal account** into it. - You **cannot merge**: - - A company account into a personal account. - - Two company accounts on private domains. + - A company account into a personal account while logged in from the personal account. + - Two company accounts on private domains if both private domains are [verified on Expensify](https://help.expensify.com/articles/new-expensify/domains/Claim-and-Verify-a-Domain). If only one of the private domains is verified, then you can sign into the Expensify account with the verified private domain, then merge the other Expensify account without the verified private domain. **Note:** If your company uses **SAML authentication**, a domain admin must approve the request. In that case, you’ll be prompted to **Switch to Classic** to initiate this. diff --git a/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md index 2ac72a6b6718..69b0e05bd42f 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md @@ -97,10 +97,10 @@ Provide the following company information: ## How to add Beneficial Owner details 1. Check the appropriate box under **Beneficial Owner**. A Beneficial Owner is an individual who owns 25% or more of the business. If no individual owns 25% or more, leave both boxes unchecked. -2. For each Beneficial Owner, enter their legal name, date of birth, Social Security Number, and address. +2. For each Beneficial Owner, enter their legal name, date of birth, the last four digits of their Social Security Number, and address. 3. Accept the agreement terms and verify that all details are true and accurate. -Expensify asks for each Beneficial Owner's complete Social Security Number in the **Full 9 digits of SSN** field. The last four digits are not enough. This differs from your own personal details, where Expensify asks only for the last four digits. Expensify does not run a personal credit check. +Expensify asks only for the last four digits of each Beneficial Owner's Social Security Number, in the **Last 4 of SSN** field. This matches your own personal details. Expensify does not run a personal credit check. --- @@ -118,12 +118,19 @@ After you submit your details: Once connected, your business bank account appears in **Account > Wallet**. If you have both personal and business bank accounts, Wallet separates them into **Personal bank accounts** and **Business bank accounts** sections. -Every bank account shows a status badge. A finished account shows **Active**, while an account you never finished setting up shows **Incomplete** along with the message **Finish adding bank account**. Click **Finish** to resume setup from where you left off. [Learn what each bank account connection status means](/articles/new-expensify/wallet-and-payments/Check-Bank-Account-and-Card-Connection-Status). +Every bank account shows a status badge. A finished account shows **Active**. An account that still needs your attention shows a badge that tells you what is left to do: + +- **Incomplete** with a **Finish adding bank account** message and a **Finish** action means setup was not submitted. Click **Finish** to resume setup from where you left off. +- **Pending** with a **Please confirm test transactions** message and a **Confirm** action means the account was submitted and is waiting on test transactions. Click the account row to open **Validate your bank account**. If **Continue setup** and **Start over** appear first, click **Continue setup** to reach it. Learn how to [validate a business bank account](/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account). +- **Verifying** means Expensify is reviewing your documentation and no action is needed from you. +- **Locked** means the account requires attention before it can be used. Learn how to [unlock a business bank account](/articles/new-expensify/wallet-and-payments/Unlock-a-Business-Bank-Account). + +[Learn what each bank account connection status means](/articles/new-expensify/wallet-and-payments/Check-Bank-Account-and-Card-Connection-Status). --- diff --git a/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md index af181f0d030c..da9a625c9ccd 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md @@ -48,8 +48,8 @@ The receiving admin must validate their copy (required for US accounts) before u - The account appears in their **Wallet**. **For US accounts:** -- The account shows a **Pending** badge with the message **Please confirm test transactions** -- The admin must validate the account with the test transactions before using it. +- The account shows a **Pending** badge with a **Please confirm test transactions** message and a **Confirm** action. +- The admin must validate the account with the test transactions before using it. Opening the account from **Wallet** shows **Validate your bank account**, or **Continue setup** and **Start over** first — in which case they click **Continue setup** to reach the validation screen. - After validation, the account is ready to use **For non-US accounts:** diff --git a/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md index 23872df6901a..deef68b7ce81 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md @@ -1,8 +1,8 @@ --- title: Validate a Business Bank Account description: Enter test transaction amounts to complete validation of a business bank account in Expensify. -keywords: [New Expensify, validate bank account, test deposits, ACH validation, business bank verification, confirm test transactions, micro-transactions, pending bank account, validation failed, test deposits wrong, Fix button missing, Unlock button missing, bank account pending, verification expired, wrong amounts, validation locked] -internalScope: Audience is all members with a pending business bank account. Covers entering test transactions to validate a bank account. Does not cover initial bank account setup, sharing, unsharing, or unlocking. +keywords: [New Expensify, validate bank account, test deposits, ACH validation, business bank verification, confirm test transactions, micro-transactions, pending bank account, Pending badge, validation failed, test deposits wrong, Fix button missing, Unlock button missing, bank account pending, verification expired, wrong amounts, validation locked, validate from Workflows, Please confirm test transactions, Continue setup, Start over] +internalScope: Audience is all members with a pending business bank account. Covers entering test transactions to validate a bank account from Wallet and from a workspace's Workflows page. Does not cover initial bank account setup, sharing, unsharing, or unlocking. --- # Validate a Business Bank Account @@ -15,7 +15,9 @@ The three transactions (two small withdrawals and one small deposit) typically a ## Who can validate a business bank account -Any member who has a **Pending** business bank account in **Wallet** can validate it using the test transactions. +Any member who has a **Pending** business bank account in **Wallet** can validate it using the test transactions. + +To validate a workspace's bank account from **Workflows**, you must be a Workspace Admin or a Payments Admin on that workspace. Members without those permissions should use **Account > Wallet** instead. --- @@ -33,19 +35,53 @@ When you see the three test amounts post to your bank account: 1. In the navigation tabs (on the left on web, on the bottom on mobile) go to **Account > Wallet**. 2. In the **Bank accounts** section, find the business bank account with the **Pending** badge and the message **Please confirm test transactions**, then click **Confirm**. -3. In the **Validate your bank account** section that appears, enter each transaction amount. -4. Click **Validate**. +3. If **Continue setup** and **Start over** appear instead of the validation screen, click **Continue setup**. Don't click **Start over**, which clears the setup already completed for the account. +4. On the **Validate your bank account** screen, enter each amount in **Transaction 1**, **Transaction 2**, and **Transaction 3**. +5. Click **Validate**. Once the values are confirmed, your business bank account is fully connected and ready to use. --- -![Account > Wallet > bank account with a callout on the Pending badge]({{site.url}}/assets/images/ExpensifyHelp-BetterBusinessBankAccount_02.png){:width="100%"} +![Account > Wallet > bank account with a Pending badge and a callout on the Confirm action]({{site.url}}/assets/images/wallet-test-transactions.png){:width="100%"} ![Account > Wallet > bank account with a Pending badge > expose validation pane]({{site.url}}/assets/images/ExpensifyHelp-BetterBusinessBankAccount_03.png){:width="100%"} --- +## How to validate a workspace business bank account from Workflows + +If the pending bank account is connected to a workspace, you can validate it from the workspace instead of from **Wallet**. Opening a pending US bank account here goes straight to **Validate your bank account** — the **Continue setup** and **Start over** options are not shown, because the account has already been submitted. + +1. In the navigation tabs (on the left on web, on the bottom on mobile), click **Workspaces > [Workspace name]**. +2. Select **Workflows**. +3. In the **Payments** section, click the bank account with the **Pending** badge. +4. On the **Validate your bank account** screen, enter each amount in **Transaction 1**, **Transaction 2**, and **Transaction 3**. +5. Click **Validate**. + +This step requires an internet connection. While you are offline, the **Payments** section in **Workflows** is disabled, so use **Account > Wallet** instead. + + + +--- + +## What happens when you open a pending US business bank account + +A pending US business bank account has already been submitted, so there is no setup left to finish. Which screen opens depends on how you get there: + +- **Workflows**, or **Account > Wallet** when you are a Workspace Admin or Payments Admin on the workspace the account belongs to: Expensify takes you straight to **Validate your bank account**, and **Continue setup** and **Start over** are not shown. +- **Account > Wallet** in every other case, including a business bank account that is not connected to a workspace: **Continue setup** and **Start over** are shown first. Click **Continue setup** to reach the same **Validate your bank account** screen. **Start over** discards the completed setup and restarts it from the beginning, so avoid it unless you intend to redo the whole account. + +Either route ends on the same validation screen, and neither asks you to re-enter company or ownership details. + +This applies only to US bank accounts, because they are the accounts that receive test transactions. A non-US business bank account that is still being set up shows an **Incomplete** badge with a **Finish** action, and opening it resumes setup where you left off. [Learn how to connect an international business bank account](/articles/new-expensify/wallet-and-payments/Enable-Global-Reimbursement). + +--- + ## What to do if test transactions don't arrive If the test deposits don't appear after two business days: @@ -99,15 +135,15 @@ If you have confirmed all of the above and validation still fails, contact Conci A locked bank account shows a **Locked** badge with the message **This account requires attention** and an **Unlock** button. If you do not see that button: -1. Check **Settings > Wallet** in the **Bank accounts** section and click the locked bank account. -2. If no option appears there, check **Workspaces > [Workspace Name] > Workflows** in the **Payments** section. +1. Check **Account > Wallet** in the **Bank accounts** section and click the locked bank account. +2. If no option appears there, check **Workspaces > [Workspace name] > Workflows** in the **Payments** section. 3. Also look for a time-sensitive alert on the **Home** screen, which may include a **Fix** button. If you still cannot find the option, refer to [Unlock a Business Bank Account](https://help.expensify.com/articles/new-expensify/wallet-and-payments/Unlock-a-Business-Bank-Account) for detailed steps, or contact Concierge. ## Validation is temporarily locked after failed attempts -If you enter incorrect amounts multiple times, validation is temporarily locked to protect your account. The lock typically lasts a few hours. After the lock period ends, you can try again by going to **Settings > Wallet** and clicking the bank account. +If you enter incorrect amounts multiple times, validation is temporarily locked to protect your account. The lock typically lasts a few hours. After the lock period ends, you can try again by going to **Account > Wallet** and clicking the bank account. If you are still unable to validate after the lock period has passed, contact Concierge. diff --git a/docs/articles/new-expensify/workspaces/Add-Approvals.md b/docs/articles/new-expensify/workspaces/Add-Approvals.md index 24817d75d09c..5f9dbd621ad7 100644 --- a/docs/articles/new-expensify/workspaces/Add-Approvals.md +++ b/docs/articles/new-expensify/workspaces/Add-Approvals.md @@ -1,6 +1,8 @@ --- title: Add Approvals description: Require report approvals in your Expensify workspace, including setting approval thresholds and over-limit approvers. +keywords: [New Expensify, approvals, report approvals, approving reports, approval workflow, workspace workflows, approver, final approver, approval chain, approval threshold, approval limit, over-limit approval, report amount limit, dollar limit approver, reassign approver, automatic approver reassignment, change approver workflow, automatic approval, approval audit, category approver, tag approver, category rules, tag rules, invite member to approval workflow, invite new user approval workflow, import workflows, import approval workflows, bulk approval workflows, spreadsheet import, multiple approval paths, multiple approvers, shared approver, audit team approver, forward to different approver, approval path per submitter, edit approvals in Expensify Classic] +internalScope: Audience is Workspace Admins and People Admins, covers enabling approvals and building default, custom, and multi-path approval workflows in New Expensify, does not cover Category and Tag approver setup or connecting a bank account. keywords: [New Expensify, approvals, report approvals, approving reports, approval workflow, workspace workflows, approver, final approver, approval chain, approval threshold, approval limit, over-limit approval, report amount limit, dollar limit approver, remove limit, remove approval limit, reassign approver, automatic approver reassignment, change approver workflow, approval workflow change message, admins room system message, automatic approval, approval audit, category approver, tag approver, category rules, tag rules, invite member to approval workflow, invite new user approval workflow, import workflows, import approval workflows, bulk approval workflows, spreadsheet import] --- @@ -64,6 +66,33 @@ Once the limit is set, the approver's row shows a hint such as `Reports above $1 **Note:** Custom workflows are only available on the Control plan. To enable custom workflows, upgrade to the Control plan in Settings > Billing & Subscriptions. +## Send one approver's reports to different next approvers + +An approver can forward to a different next approver depending on who submitted the report. This lets a shared reviewer — an audit team or an office manager, for example — sit in the middle of several approval paths without sending every report they approve down the same route. + +For example, you can set up both of these approval paths on the same workspace: + +- Employee A → Audit team → Manager A +- Employee C → Audit team → Manager C + +The Audit team approves both reports, but each report continues to a different next approver. + +To set this up, create a separate approval workflow for each submitter and give each one its own next approver after the shared approver: + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [Workspace Name] > Workflows**. +2. On the **Approvals** tab, click **Add approval workflow**. +3. On the **Expenses from** page, choose the member whose expenses should follow this path. +4. Click **Next**. +5. Select the shared approver as the first approver. +6. Click **Next**. +7. Use **Additional approver** to add the next approver for this submitter. +8. Click **Add workflow** to save. +9. Repeat these steps for each submitter who routes through the same shared approver to a different next approver. + +Editing one of these workflows changes only that workflow. The other workflows that use the same approver keep their own next approvers. + +**Note:** Multiple approval paths through the same approver require the Control plan, and they can only be created and edited in New Expensify. + --- ## How to import approval workflows from a spreadsheet @@ -163,22 +192,26 @@ No. Each employee can only be assigned one approval workflow per workspace. ## Why did adding an approver to one workflow change other members' workflows? -Approval routing is built from per-approver relationships, not separate paths for each submitter. When you place someone *after* an approver in a workflow, you're defining who that approver forwards their approvals to — and that applies to **every** report they approve, not just one submitter's. - -For example, if Joe is the first approver for several members and you set up a workflow of **John → Joe → Jane**, you're telling Expensify that "Joe forwards to Jane." Because Joe also approves for other members, their reports will now route to Jane after Joe approves them too. +Each approval workflow keeps its own approvers, so adding or changing an approver in one workflow doesn't change another workflow that uses the same approver. A shared approver can forward to a different next approver in each workflow they belong to. -This is why you may see the following warning when editing a workflow: +You'll see the message below only on workspaces whose approvals are still routed by per-approver relationships rather than by separate paths for each submitter: > This member already belongs to another approval workflow. Any updates here will reflect there too. -Deleting and recreating the workflow won't change this, because the limitation is tied to the shared approver's forwarding rather than to any one submitter's workflow. +On those workspaces, placing someone *after* an approver defines who that approver forwards to for **every** report they approve. For example, if Joe is the first approver for several members and you set up **John → Joe → Jane**, other members' reports also route to Jane after Joe approves them. Deleting and recreating the workflow won't change that, because the behavior is tied to the shared approver's forwarding rather than to any one submitter's workflow. -To keep a second-level approver limited to a single submitter, that submitter's first approver must be unique to their chain — that is, not shared as an approver for any other member. If your goal is instead to add an extra review step only when a report exceeds a certain amount, use the workflow-specific over-limit **Additional approver** (set in the **Report amount** field), which applies only to that workflow. +If your goal is to add an extra review step only when a report exceeds a certain amount, use the workflow-specific over-limit **Additional approver** (set in the **Report amount** field), which applies only to that workflow. ## How can I assign custom approval workflows to specific members? You’ll need to be on the Control plan, as this feature isn’t available on Collect. +## Why can't I see or edit approval settings in Expensify Classic? + +Expensify Classic can't represent a workspace where one approver forwards to different next approvers. On those workspaces, Classic hides the approval fields on the **Members** page and in the member settings, and shows a banner linking you to New Expensify. Make your approval changes in New Expensify instead. + +If you later remove the extra paths so that every approver forwards to a single next approver, the banner disappears and the approval fields return in Classic. + ## What happens to pending reports when I change an approver? Outstanding reports are automatically reassigned to the new approver, with no prompt or confirmation required. A system message appears in each report indicating the approver was reassigned. This applies to reports that are already in the approval process, not just new reports, and it happens in these situations: diff --git a/docs/articles/new-expensify/workspaces/Create-and-manage-expense-tags.md b/docs/articles/new-expensify/workspaces/Create-and-manage-expense-tags.md index 28d68aefbb8d..a31e25113360 100644 --- a/docs/articles/new-expensify/workspaces/Create-and-manage-expense-tags.md +++ b/docs/articles/new-expensify/workspaces/Create-and-manage-expense-tags.md @@ -1,7 +1,7 @@ --- title: Create and Manage Expense Tags description: Add tags to use for coding expenses. -keywords: [New Expensify, expense tags, class tracking, cost center, import tags, coding expenses, tag GL code, tag approver, tag rules, tag approval] +keywords: [New Expensify, expense tags, class tracking, cost center, import tags, coding expenses, tag GL code, multi-level tag GL code, dependent tag GL code, tag approver, tag rules, tag approval] internalScope: Audience is Workspace Admins. Covers enabling, creating, importing, activating, and managing expense tags, including multi-level tags, tag GL codes, and tag approvers (Tag Rules) and how Tag/Category/workspace approvers combine in the approval chain. Does not cover personal expense rules or accounting system configuration. --- @@ -105,7 +105,7 @@ Example: If you use single-level Tags for Department, members will see one field **Note:** - With single-level Tags, you can edit or delete individual tags directly in the workspace. -- With multi-level Tags, you must update and re-import your spreadsheet to make changes. +- With multi-level Tags, you must update and re-import your spreadsheet to make changes. The exception is tag GL codes, which you can edit directly in the workspace. --- @@ -157,12 +157,15 @@ When importing multi-level Tags, enable **These are independent tags** to import ## How to add or edit a tag GL code -Workspaces on the Control plan can assign a GL code to each tag for exporting purposes. By default, tag GL codes are not visible to members, but you can choose to show them in the tag picker. +Workspaces on the Control plan can assign a GL code to each tag for exporting purposes. This includes single-level tags and every level of dependent or independent multi-level tags. By default, tag GL codes are not visible to members, but you can choose to show them in the tag picker. 1. In the **navigation tabs** (on the left on web, and at the bottom on mobile), select **Workspaces** > **[workspace name]**. 2. Select **Tags**. -3. Select a tag to open its detail panel. -4. Select the **GL Code** field, enter or update the code, then select **Save**. +3. If you use multi-level tags, select the tag list for the level you want to update (for example: **State**, **Region**, or **City**). +4. Select a tag to open its detail panel. +5. Select **GL code**, enter or update the code, then select **Save**. + +For dependent multi-level tags, each GL code is saved to the tag you selected under its parent tags. If the same tag name appears under different parents (for example: **Springfield** under both **Illinois** and **Missouri**), each one keeps its own GL code. --- @@ -248,6 +251,10 @@ Multi-level tags are available on Control workspaces only. If your Control works By default, no. Tag GL codes are visible only to Workspace Admins. However, a Workspace Admin can turn on **Show GL codes when selecting a tag** in **Tags** > **More** > **Settings** to display each tag's GL code in the tag picker for members. If that setting is off, you can still include the GL code in the tag name (for example: `1001 - Marketing`). +## Can I add GL codes to dependent multi-level tags? + +Yes. Dependent multi-level tags support a GL code at each level, the same as independent multi-level tags. You can include GL codes in the adjacent columns when you import your spreadsheet, or edit a tag's **GL code** directly in the workspace. + ## Why isn't a GL code displayed for a tag? If **Show GL codes when selecting a tag** is on but a tag has no GL code next to it, that tag doesn't have a GL code available in your accounting system. Add the GL code in your accounting system and sync the connection so it appears in Expensify. diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index e5a273b9d9ac..bcead478f396 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -15,7 +15,7 @@ Distance rates determine how much members are reimbursed for distance-based trav Workspace admins can configure distance rates for a workspace. -Automatic updates are available for Control workspaces with a default currency of USD, CAD, GBP, or AUD. +Automatic updates are available for Control workspaces with a default currency of USD, CAD, GBP, AUD, NOK, SEK, or ZAR. --- @@ -94,7 +94,7 @@ Expensify can automatically update distance rates in your workspace when the sta When **Auto-update government rates** is enabled, Expensify adds a new effective-dated distance rate when a supported government publishes a new rate. This means admins don't need to manually update the standard rate each time it changes. -Expensify uses the workspace default currency to determine which government publishes the rate: USD for the United States, CAD for Canada, GBP for Great Britain, and AUD for Australia. +Expensify uses the workspace default currency to determine which government publishes the rate: USD for the United States, CAD for Canada, GBP for Great Britain, AUD for Australia, NOK for Norway, SEK for Sweden, and ZAR for South Africa. To enable automatic updates: @@ -134,7 +134,7 @@ Changes Expensify makes on its own, such as converting a rate between **Miles** Expensify determines the publishing government from the workspace default currency, so changing the default currency changes which government rate applies. When **Auto-update government rates** is enabled and you change the default currency to another supported currency: - Expensify adds a new government rate for the new currency's country, with the lightning bolt icon. -- Expensify sets the workspace **Unit** to the unit that country publishes in: **Miles** for the United States and Great Britain, and **Kilometers** for Canada and Australia. +- Expensify sets the workspace **Unit** to the unit that country publishes in: **Miles** for the United States and Great Britain, and **Kilometers** for Canada, Australia, Norway, Sweden, and South Africa. - Government rates created before the change remain in the workspace, converted to the new **Unit** so every rate is shown in the same unit. - Those earlier rates keep the lightning bolt icon, because converting a rate to a different unit is not a manual edit. Rates that an admin edited stay without the icon. @@ -184,7 +184,7 @@ Yes. When **Distance rates** is enabled, the workspace must always have at least ## Why isn't Auto-update government rates turned on for my workspace? -**Auto-update government rates** is turned on by default only for new workspaces. Existing workspaces are opted out by default, so you'll need to turn it on manually. It's also only available for workspaces with a default currency of USD, CAD, GBP, or AUD. +**Auto-update government rates** is turned on by default only for new workspaces. Existing workspaces are opted out by default, so you'll need to turn it on manually. It's also only available for workspaces with a default currency of USD, CAD, GBP, AUD, NOK, SEK, or ZAR. ## Why did the lightning bolt icon disappear from one of my distance rates? diff --git a/docs/articles/new-expensify/workspaces/Workspace-Workflows.md b/docs/articles/new-expensify/workspaces/Workspace-Workflows.md index 4604bdf270d3..0d19705b4a6f 100644 --- a/docs/articles/new-expensify/workspaces/Workspace-Workflows.md +++ b/docs/articles/new-expensify/workspaces/Workspace-Workflows.md @@ -1,5 +1,8 @@ --- title: Workspace Workflows +description: Configure your workspace approval and submission workflows to match your team's needs. +keywords: [New Expensify, workflows, workflows tabs, submissions, approvals, payments, advanced, approval workflows, delay submission, add approver, connect bank, workspace settings, submission frequency, authorized payer, mark as paid, payer, prevent self-approvals, auto-approve compliant reports, auto-pay approved reports, multiple approval paths, shared approver, forward to different approver] +internalScope: Audience is Workspace Admins. Covers configuring approval, submission, reimbursement, and advanced report workflows including choosing an authorized payer; does not cover connecting a bank account. description: Configure your workspace submission, approval, and payment workflows to match your team's needs. keywords: [New Expensify, workflows, approval workflows, delay submission, add approver, connect bank, workspace settings, submission frequency, authorized payer, mark as paid, payer, who can pay a report] internalScope: Audience is workspace admins, people admins and payment admins. Covers configuring approval, submission, and reimbursement workflows including choosing an authorized payer; does not cover connecting a bank account. @@ -45,11 +48,14 @@ Once enabled, admins with the appropriate permissions can configure workflow set Approvals control who reviews expenses before payment. - You can assign an approver per workspace member. +- An approver can forward to a different next approver depending on who submitted the report, so a shared reviewer such as an audit team can sit in more than one approval path. - You can also set an over-limit approver for reports that exceed a specific amount. -- The default approver is the workspace owner, but any workspace member can be selected. -- Both workspace admins and people admins can enable and configure Approvals. +- The default approver is the **Workspace Owner**, but any workspace member can be selected. +- Both **Workspace Admins** and **People Admins** can enable and configure Approvals. -Learn how to [configure Approvals](/articles/new-expensify/workspaces/Add-Approvals). +Learn how to [enable Approvals](https://help.expensify.com/articles/new-expensify/workspaces/Add-Approvals) and [configure Approvals](/articles/new-expensify/workspaces/Add-Approvals) on your workspace. + +Workspaces that route one approver to different next approvers can only be configured in New Expensify. [Learn how to send one approver's reports to different next approvers](/articles/new-expensify/workspaces/Add-Approvals#send-one-approvers-reports-to-different-next-approvers). ## How Submissions work diff --git a/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md b/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md index d8ea2244fe57..8ce3b9aac574 100644 --- a/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md +++ b/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md @@ -13,6 +13,8 @@ After you're assigned the Travel Arranger role, you can arrange travel for anoth Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. +You can also select **Search** (the magnifying glass icon), type **Book travel**, then select the **Book travel** result. **Book travel** appears in both places only when Expensify Travel is enabled on your active workspace. + If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. --- diff --git a/docs/articles/travel/booking-travel/Book-Rail-Travel.md b/docs/articles/travel/booking-travel/Book-Rail-Travel.md index 711d54c0b471..d3c46cf16ca7 100644 --- a/docs/articles/travel/booking-travel/Book-Rail-Travel.md +++ b/docs/articles/travel/booking-travel/Book-Rail-Travel.md @@ -12,6 +12,8 @@ Book train tickets directly in Expensify Travel. This guide covers how to search Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. +You can also select **Search** (the magnifying glass icon), type **Book travel**, then select the **Book travel** result. **Book travel** appears in both places only when Expensify Travel is enabled on your active workspace. + If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. # How to book rail travel diff --git a/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md b/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md index 858cc8fd6c27..a0b3d74caf3b 100644 --- a/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md +++ b/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md @@ -13,6 +13,8 @@ Any member with guest booking permission can book travel for someone who doesn Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. +You can also select **Search** (the magnifying glass icon), type **Book travel**, then select the **Book travel** result. **Book travel** appears in both places only when Expensify Travel is enabled on your active workspace. + If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. --- diff --git a/docs/articles/travel/booking-travel/Book-a-Flight.md b/docs/articles/travel/booking-travel/Book-a-Flight.md index b8a965991c2c..027d856aa046 100644 --- a/docs/articles/travel/booking-travel/Book-a-Flight.md +++ b/docs/articles/travel/booking-travel/Book-a-Flight.md @@ -13,6 +13,8 @@ Book flights easily with Expensify Travel. This guide walks you through finding Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. +You can also select **Search** (the magnifying glass icon), type **Book travel**, then select the **Book travel** result. **Book travel** appears in both places only when Expensify Travel is enabled on your active workspace. + If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. # How to book a flight diff --git a/docs/articles/travel/booking-travel/Book-a-Hotel.md b/docs/articles/travel/booking-travel/Book-a-Hotel.md index b3957baa9faa..b6666e1e1884 100644 --- a/docs/articles/travel/booking-travel/Book-a-Hotel.md +++ b/docs/articles/travel/booking-travel/Book-a-Hotel.md @@ -12,6 +12,8 @@ Book hotels easily with Expensify Travel. This guide covers how to search for ho Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. +You can also select **Search** (the magnifying glass icon), type **Book travel**, then select the **Book travel** result. **Book travel** appears in both places only when Expensify Travel is enabled on your active workspace. + If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. # How to book a hotel diff --git a/docs/articles/travel/booking-travel/Book-a-Rental-Car.md b/docs/articles/travel/booking-travel/Book-a-Rental-Car.md index 944ac6bba680..f84241f502a8 100644 --- a/docs/articles/travel/booking-travel/Book-a-Rental-Car.md +++ b/docs/articles/travel/booking-travel/Book-a-Rental-Car.md @@ -12,6 +12,8 @@ Book rental cars quickly and easily through Expensify’s integrated travel port Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. +You can also select **Search** (the magnifying glass icon), type **Book travel**, then select the **Book travel** result. **Book travel** appears in both places only when Expensify Travel is enabled on your active workspace. + If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. # How to book a rental car diff --git a/docs/assets/images/Companycards_add_cards.png b/docs/assets/images/Companycards_add_cards.png new file mode 100644 index 000000000000..cd221e48c821 Binary files /dev/null and b/docs/assets/images/Companycards_add_cards.png differ diff --git a/docs/assets/images/Companycards_file_import.png b/docs/assets/images/Companycards_file_import.png new file mode 100644 index 000000000000..b203b881368f Binary files /dev/null and b/docs/assets/images/Companycards_file_import.png differ diff --git a/docs/assets/images/Expense_select-vendor.png b/docs/assets/images/Expense_select-vendor.png new file mode 100644 index 000000000000..d9b828123ee2 Binary files /dev/null and b/docs/assets/images/Expense_select-vendor.png differ diff --git a/docs/assets/images/wallet-test-transactions.png b/docs/assets/images/wallet-test-transactions.png new file mode 100644 index 000000000000..479bc405c9e9 Binary files /dev/null and b/docs/assets/images/wallet-test-transactions.png differ diff --git a/eslint-plugin-local-rules/no-direct-personal-details-list.js b/eslint-plugin-local-rules/no-direct-personal-details-list.js index e7798f05a117..5ede37c804a0 100644 --- a/eslint-plugin-local-rules/no-direct-personal-details-list.js +++ b/eslint-plugin-local-rules/no-direct-personal-details-list.js @@ -38,6 +38,38 @@ function getStaticPropertyName(node) { return undefined; } +/** + * Search snapshot payloads key their personal details by the same string as the Onyx key, so building one + * (`data[ONYXKEYS.PERSONAL_DETAILS_LIST] = …`, `Object.assign(data, {[ONYXKEYS.PERSONAL_DETAILS_LIST]: …})`, + * `{data: {[ONYXKEYS.PERSONAL_DETAILS_LIST]: …}}`) names a payload field rather than touching the Onyx key. + * + * @param {import('estree').MemberExpression & import('eslint').Rule.NodeParentExtension} node + * @returns {boolean} + */ +function isSnapshotPayloadKey(node) { + const parent = node.parent; + if (parent.type === 'MemberExpression') { + return parent.computed && parent.property === node && parent.parent.type === 'AssignmentExpression' && parent.parent.left === parent; + } + if (parent.type !== 'Property' || !parent.computed || parent.key !== node) { + return false; + } + const objectLiteral = parent.parent; + const container = objectLiteral.parent; + if (container.type === 'Property') { + return container.value === objectLiteral && !container.computed && container.key.type === 'Identifier' && container.key.name === 'data'; + } + return ( + container.type === 'CallExpression' && + container.arguments.indexOf(objectLiteral) > 0 && + container.callee.type === 'MemberExpression' && + container.callee.object.type === 'Identifier' && + container.callee.object.name === 'Object' && + container.callee.property.type === 'Identifier' && + container.callee.property.name === 'assign' + ); +} + /** * @param {import('eslint').Rule.RuleContext} context * @returns {import('eslint').Rule.RuleListener} @@ -50,7 +82,7 @@ function create(context) { if (node.object.type !== 'Identifier' || node.object.name !== ONYXKEYS_IDENTIFIER) { return; } - if (getStaticPropertyName(node) !== PERSONAL_DETAILS_LIST_PROPERTY) { + if (getStaticPropertyName(node) !== PERSONAL_DETAILS_LIST_PROPERTY || isSnapshotPayloadKey(node)) { return; } context.report({node, messageId: 'directUsage'}); diff --git a/index.js b/index.js index cf005180666d..eee4a231fd3d 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,7 @@ import './src/polyfills/NitroFetch'; import './src/polyfills/PromiseWithResolvers'; import './src/polyfills/requestIdleCallback'; +import './src/polyfills/SymbolDispose'; import {AppRegistry} from 'react-native'; import App from './src/App'; import Config from './src/CONFIG'; diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 55345eddc24f..20b8325c9905 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.4.95 + 9.5.2 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.95.0 + 9.5.2.4 FullStory OrgId @@ -95,6 +95,8 @@ RCTNewArchEnabled + RNNitroSQLite_DatabaseLocation + ApplicationSupport UIAppFonts ExpensifyNewKansas-Medium.otf diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 687fba77a262..8bd1c0acde15 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.95 + 9.5.2 CFBundleVersion - 9.4.95.0 + 9.5.2.4 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5a54a64a1e5a..63acb97ed248 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2928,7 +2928,7 @@ PODS: - React-Core - RNFS (2.20.0): - React-Core - - RNGestureHandler (2.32.0): + - RNGestureHandler (3.3.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2950,6 +2950,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies + - RNWorklets (>= 0.8.0) - Yoga - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) @@ -3028,7 +3029,12 @@ PODS: - ReactNativeDependencies - Turf - Yoga - - RNNitroSQLite (9.6.0): + - RNNitroSQLite (9.8.3): + - boost + - DoubleConversion + - fast_float + - fmt + - glog - hermes-engine - NitroModules - RCTRequired @@ -4101,12 +4107,12 @@ SPEC CHECKSUMS: RNFBAnalytics: 2f451df50833a890974d55a93557da878a85b29e RNFBApp: db9c2e6d36fe579ab19b82c0a4a417ff7569db7e RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 - RNGestureHandler: 7b07d9192bc65c6883fb37fdecc05dc6b61c814f + RNGestureHandler: 85e32defcf23dd3b023356355c5c1dd1d9d51bde RNGoogleSignin: 89877c73f0fbf6af2038fbdb7b73b5a25b8330cc RNLiveMarkdown: f4c383d846d5d756f42bdf6006e85d5baf0ebfc8 RNLocalize: 82e7aa2857ce5dcd9e2a0c04f8c34c2ed50821df rnmapbox-maps: 54eb40428a4fd89f484a492fc7186bc1c1eea9ce - RNNitroSQLite: e354c4a31133050e6bbde1c136b8ac4857830236 + RNNitroSQLite: c986393b7cb0e5e4587d0c9f7108e56ee8db9627 RNPermissions: 32bd3b977f28442af0900b102f4458f0f9f9b77c RNReactNativeHapticFeedback: 6c8677db48f62a635e6a2065f5d2bd02498d7a73 RNReanimated: f093c30aa11e45a8044dcc19417f44cffa1c4e1d diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index b8dc635c683e..f350b63c1d2e 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.95 + 9.5.2 CFBundleVersion - 9.4.95.0 + 9.5.2.4 NSExtension NSExtensionAttributes diff --git a/jest.config.js b/jest.config.js index e8e628bb3bd9..58b96c5f43b7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -27,10 +27,10 @@ module.exports = { `/?(*.)+(spec|test).${testFileExtension}`, ], transform: { - // Reassure re-transforms ~7k files under `--max-opt=1` (V8 sparkplug only), which - // makes Babel ~half of each measure job. OXC + esbuild is native and stays fast - // without TurboFan. Test files stay on babel-jest so `jest.mock` is still hoisted. - '^.+\\.[jt]sx?$': isPerfTestRun ? '/config/babel/oxcJestTransformer.js' : 'babel-jest', + // OXC + esbuild is native and stays fast without TurboFan, where Babel was measured as + // roughly half of each Reassure measure job. The transformer sends test files, mocks and + // node_modules to babel-jest itself, so `jest.mock` is still hoisted there. + '^.+\\.[jt]sx?$': '/config/babel/oxcJestTransformer.js', '^.+\\.svg?$': 'jest-transformer-svg', }, transformIgnorePatterns: [ @@ -49,6 +49,8 @@ module.exports = { globals: { __DEV__: true, WebSocket: {}, + // Build-time define from config/rsbuild/rsbuild.common.ts. SkiaWebChart reads it when mounting CanvasKit. + __CANVASKIT_WASM_URL__: '/canvaskit-test.wasm', }, fakeTimers: { enableGlobally: true, diff --git a/jest/setup.ts b/jest/setup.ts index 44fbb6e59862..9afaa8f219a1 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -38,6 +38,14 @@ Object.assign(global, {TextDecoder, TextEncoder}); // https://reactnavigation.org/docs/testing/#mocking-native-modules jest.mock('react-native/src/private/animated/NativeAnimatedHelper'); +// Gesture Handler 3 throws in __DEV__ when a GestureDetector has no GestureHandlerRootView above it. In the app every screen +// renders under the root view in App.tsx, but tests render screens on their own, so the context pretends a root view is always present. +// Jest resolves the package through `main` (lib/module), so the compiled file is the one to mock. +jest.mock('react-native-gesture-handler/lib/module/GestureHandlerRootViewContext', () => ({ + __esModule: true, + default: jest.requireActual('react').createContext(true), +})); + // Mock react-native-onyx storage layer because the SQLite storage layer doesn't work in jest. // Mocking this file in __mocks__ does not work because jest doesn't support mocking files that are not directly used in the testing project, // and we only want to mock the storage layer, not the whole Onyx module. @@ -144,6 +152,11 @@ jest.mock('react-native-reanimated', () => ({ // react-native-reanimated/mock leaves dispatchCommand out (see its own "ADD ME IF NEEDED" comment). forceClearInput // (src/libs/ComponentUtils) dispatches it from a UI-thread worklet, so any test exercising that path needs it mocked. dispatchCommand: jest.fn(), + // react-native-reanimated/mock also leaves out useComposedEventHandler, useHandler and isSharedValue, which Gesture Handler 3 + // calls from its detectors (including the ones behind its ScrollView / FlatList wrappers). + useComposedEventHandler: jest.fn(() => () => {}), + useHandler: jest.fn(() => ({context: {}, doDependenciesDiffer: false})), + isSharedValue: jest.fn((value: unknown) => typeof value === 'object' && value !== null && 'value' in value && 'get' in value && typeof value.get === 'function'), useReducedMotion: jest.fn, useScrollViewOffset: jest.fn(() => 0), useAnimatedRef: jest.fn(() => jest.fn()), diff --git a/package-lock.json b/package-lock.json index 39b864a769ef..17599b761265 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.95-0", + "version": "9.5.2-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.95-0", + "version": "9.5.2-4", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -62,6 +62,7 @@ "array.prototype.tosorted": "^1.1.4", "awesome-phonenumber": "^5.4.0", "canvas-size": "^1.2.6", + "canvaskit-wasm": "0.41.0", "d3-scale": "^4.0.2", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", @@ -115,7 +116,7 @@ "react-native-device-info": "10.3.1", "react-native-draggable-flatlist": "^4.0.3", "react-native-fs": "^2.20.0", - "react-native-gesture-handler": "2.32.0", + "react-native-gesture-handler": "3.3.0", "react-native-google-places-autocomplete": "2.6.4", "react-native-haptic-feedback": "^2.3.3", "react-native-image-picker": "^7.1.2", @@ -125,8 +126,8 @@ "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", - "react-native-nitro-sqlite": "9.6.0", - "react-native-onyx": "3.0.113", + "react-native-nitro-sqlite": "9.8.3", + "react-native-onyx": "3.0.115", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -5219,16 +5220,6 @@ "url": "https://github.com/sponsors/dword-design" } }, - "node_modules/@egjs/hammerjs": { - "version": "2.0.17", - "license": "MIT", - "dependencies": { - "@types/hammerjs": "^2.0.36" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/@emnapi/core": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", @@ -5882,10 +5873,20 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -8504,9 +8505,9 @@ } }, "node_modules/@expo/xcpretty/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -14367,29 +14368,24 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", + "version": "2.0.5", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", + "version": "1.1.1", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", + "version": "1.1.1", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { "version": "1.0.2", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "license": "BSD-3-Clause" @@ -14399,7 +14395,7 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", + "version": "1.1.1", "license": "BSD-3-Clause" }, "node_modules/@pusher/pusher-websocket-react-native": { @@ -14562,10 +14558,20 @@ } }, "node_modules/@react-native-community/cli-config/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -16123,9 +16129,9 @@ } }, "node_modules/@rsbuild/plugin-svgr/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -19965,10 +19971,6 @@ "@types/node": "*" } }, - "node_modules/@types/hammerjs": { - "version": "2.0.41", - "license": "MIT" - }, "node_modules/@types/howler": { "version": "2.2.12", "dev": true, @@ -21571,13 +21573,13 @@ } }, "node_modules/adm-zip": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", - "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz", + "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0" + "node": ">=14.0" } }, "node_modules/agent-base": { @@ -23963,19 +23965,6 @@ "node": ">=8" } }, - "node_modules/concurrently/node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/concurrently/node_modules/supports-color": { "version": "8.1.1", "dev": true, @@ -29314,17 +29303,6 @@ "hermes-estree": "0.36.1" } }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "license": "MIT" - }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -32854,7 +32832,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.1", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -33398,19 +33378,6 @@ "shell-quote": "^1.8.4" } }, - "node_modules/launch-editor/node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/lefthook": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.9.tgz", @@ -36841,22 +36808,21 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.3", + "version": "7.6.6", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -37485,14 +37451,12 @@ } }, "node_modules/react-native-gesture-handler": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.32.0.tgz", - "integrity": "sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-3.3.0.tgz", + "integrity": "sha512-ZCTlJ09nE3GPbm7J74lvwpxmx1TQ/7MDlHZNLARVYTN82k21mJUJ7BmI9eWjEi8eevHiAudI5O1Cpnt0rJfoJA==", "license": "MIT", "dependencies": { - "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", - "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { @@ -37699,9 +37663,9 @@ } }, "node_modules/react-native-nitro-sqlite": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-9.6.0.tgz", - "integrity": "sha512-a/N1yGhM8RvCCnaYhEHhh35YS+HDOAcGKeKFsp2ExCzIjP8vPXuzQtHylgLQLeAh7rUaism5q0QQFfogXm1SXA==", + "version": "9.8.3", + "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-9.8.3.tgz", + "integrity": "sha512-pw4ZNYNQiMiyJZr6bu0dg+IPTskoEngg1wZBD/H5bg1oyH1rJM4HfS5nVgU7jydibIMko1YhrHK+3m5AEndcDg==", "license": "MIT", "dependencies": { "typeorm": "0.3.27" @@ -37878,9 +37842,9 @@ } }, "node_modules/react-native-onyx": { - "version": "3.0.113", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.113.tgz", - "integrity": "sha512-6+pk3ArtP2QcsH+IlXLgNWeWiC7c9abwoTVs9yxzWuMUHlHkyQ7QvsxMM5f2iYlX3qBcFkHqU2puGsnhhKy7mw==", + "version": "3.0.115", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.115.tgz", + "integrity": "sha512-6JLYMjsSD/tGWFK/zGsQs7ml+LCKK5gfjF4JhWhg5dAW1rzZ91S63dYoi80GU31nIXcP8avN8q7M2FtOInhfNg==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -39768,9 +39732,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -42446,7 +42410,9 @@ } }, "node_modules/underscore": { - "version": "1.13.6", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, "node_modules/undici": { diff --git a/package.json b/package.json index f7e48ffc1f31..7842b155e40e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.95-0", + "version": "9.5.2-4", "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.", @@ -138,6 +138,7 @@ "array.prototype.tosorted": "^1.1.4", "awesome-phonenumber": "^5.4.0", "canvas-size": "^1.2.6", + "canvaskit-wasm": "0.41.0", "d3-scale": "^4.0.2", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", @@ -191,7 +192,7 @@ "react-native-device-info": "10.3.1", "react-native-draggable-flatlist": "^4.0.3", "react-native-fs": "^2.20.0", - "react-native-gesture-handler": "2.32.0", + "react-native-gesture-handler": "3.3.0", "react-native-google-places-autocomplete": "2.6.4", "react-native-haptic-feedback": "^2.3.3", "react-native-image-picker": "^7.1.2", @@ -201,8 +202,8 @@ "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.6.2", "react-native-nitro-modules": "0.37.1", - "react-native-nitro-sqlite": "9.6.0", - "react-native-onyx": "3.0.113", + "react-native-nitro-sqlite": "9.8.3", + "react-native-onyx": "3.0.115", "react-native-pager-view": "9.0.4", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -401,6 +402,7 @@ "path-to-regexp": "0.1.10", "send": "0.19.0", "regexpu-core": "6.4.0", + "adm-zip": "0.6.1", "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260507", "react": "19.2.3", "react-dom": "19.2.3", @@ -453,6 +455,10 @@ } } }, + "nitroSQLite": { + "threadSafe": true, + "performanceMode": true + }, "engines": { "bun": "1.3.14", "node": "26.5.0", diff --git a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch new file mode 100644 index 000000000000..4848d545479f --- /dev/null +++ b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch @@ -0,0 +1,78 @@ +diff --git a/node_modules/@shopify/react-native-skia/lib/commonjs/web/LoadSkiaWeb.js b/node_modules/@shopify/react-native-skia/lib/commonjs/web/LoadSkiaWeb.js +index 571eca9..061b15c 100644 +--- a/node_modules/@shopify/react-native-skia/lib/commonjs/web/LoadSkiaWeb.js ++++ b/node_modules/@shopify/react-native-skia/lib/commonjs/web/LoadSkiaWeb.js +@@ -10,12 +10,33 @@ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e + // @ts-ignore + + let ckSharedPromise; ++ ++// The glue (`canvaskit.js`) and the binary (`canvaskit.wasm`) are only compatible within one canvaskit-wasm ++// release. When they come from different releases the binary can still link, but the glue then reads the ++// exports under stale names and the embind constructors are never registered: `CanvasKitInit` resolves with ++// an object that has none of its classes, and `Skia.web.ts` would happily wrap it until the first draw throws ++// `PictureRecorder is not a constructor` from a worklet, out of reach of any React error boundary. ++// Check a couple of embind classes here so the mismatch surfaces at load time instead. ++// See patches/@shopify/react-native-skia and https://github.com/Expensify/App/issues/102042. ++const isUsableCanvasKit = CanvasKit => typeof CanvasKit === "object" && CanvasKit !== null && typeof CanvasKit.PictureRecorder === "function" && typeof CanvasKit.Paint === "function"; + const LoadSkiaWeb = async opts => { + if (global.CanvasKit !== undefined) { + return; + } + ckSharedPromise = ckSharedPromise !== null && ckSharedPromise !== void 0 ? ckSharedPromise : (0, _canvaskit.default)(opts); +- const CanvasKit = await ckSharedPromise; ++ let CanvasKit; ++ try { ++ CanvasKit = await ckSharedPromise; ++ } catch (error) { ++ // Don't cache the rejection: a later mount (different network conditions, or a reload of a stale ++ // cached binary) should get a fresh attempt instead of re-throwing the first failure forever. ++ ckSharedPromise = undefined; ++ throw error; ++ } ++ if (!isUsableCanvasKit(CanvasKit)) { ++ ckSharedPromise = undefined; ++ throw new Error("CanvasKit initialized without its bindings; canvaskit.js and canvaskit.wasm are probably from different versions"); ++ } + // The CanvasKit API is stored on the global object and used + // to create the JsiSKApi in the Skia.web.ts file. + global.CanvasKit = CanvasKit; +diff --git a/node_modules/@shopify/react-native-skia/lib/module/web/LoadSkiaWeb.js b/node_modules/@shopify/react-native-skia/lib/module/web/LoadSkiaWeb.js +index cb11667..2f5b666 100644 +--- a/node_modules/@shopify/react-native-skia/lib/module/web/LoadSkiaWeb.js ++++ b/node_modules/@shopify/react-native-skia/lib/module/web/LoadSkiaWeb.js +@@ -2,12 +2,33 @@ + // @ts-ignore + import CanvasKitInit from "canvaskit-wasm/bin/full/canvaskit"; + let ckSharedPromise; ++ ++// The glue (`canvaskit.js`) and the binary (`canvaskit.wasm`) are only compatible within one canvaskit-wasm ++// release. When they come from different releases the binary can still link, but the glue then reads the ++// exports under stale names and the embind constructors are never registered: `CanvasKitInit` resolves with ++// an object that has none of its classes, and `Skia.web.ts` would happily wrap it until the first draw throws ++// `PictureRecorder is not a constructor` from a worklet, out of reach of any React error boundary. ++// Check a couple of embind classes here so the mismatch surfaces at load time instead. ++// See patches/@shopify/react-native-skia and https://github.com/Expensify/App/issues/102042. ++const isUsableCanvasKit = CanvasKit => typeof CanvasKit === "object" && CanvasKit !== null && typeof CanvasKit.PictureRecorder === "function" && typeof CanvasKit.Paint === "function"; + export const LoadSkiaWeb = async opts => { + if (global.CanvasKit !== undefined) { + return; + } + ckSharedPromise = ckSharedPromise !== null && ckSharedPromise !== void 0 ? ckSharedPromise : CanvasKitInit(opts); +- const CanvasKit = await ckSharedPromise; ++ let CanvasKit; ++ try { ++ CanvasKit = await ckSharedPromise; ++ } catch (error) { ++ // Don't cache the rejection: a later mount (different network conditions, or a reload of a stale ++ // cached binary) should get a fresh attempt instead of re-throwing the first failure forever. ++ ckSharedPromise = undefined; ++ throw error; ++ } ++ if (!isUsableCanvasKit(CanvasKit)) { ++ ckSharedPromise = undefined; ++ throw new Error("CanvasKit initialized without its bindings; canvaskit.js and canvaskit.wasm are probably from different versions"); ++ } + // The CanvasKit API is stored on the global object and used + // to create the JsiSKApi in the Skia.web.ts file. + global.CanvasKit = CanvasKit; diff --git a/patches/@shopify/react-native-skia/details.md b/patches/@shopify/react-native-skia/details.md index 22f5616f461a..df49c88726e7 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -51,7 +51,7 @@ Fix: keep the renderer alive without a GrDirectContext and fall back to CanvasKit.MakeSWCanvasSurface in onResize, so the chart still renders (on the CPU) instead of crashing the page. If that also fails, leave this.surface null - - the constructor already initialises it to null and both draw() and + the constructor already initializes it to null and both draw() and makeImageSnapshot() null-check it. This mirrors the sibling renderPictureToSurface path, which already treats a failed WebGL surface as recoverable rather than fatal. Charts on capable clients are unaffected and @@ -159,3 +159,37 @@ - Upstream PR/issue: - E/App issue: https://github.com/Expensify/App/issues/95221 - PR introducing patch: + +### [@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch](@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch) + +- Reason: + + ``` + Makes LoadSkiaWeb fail closed when CanvasKit comes up without its bindings + (Sentry APP-M73 / APP-M7F: "PictureRecorder is not a constructor"), and stops + it from caching a rejected init forever (APP-M6B / APP-M76: the LinkError was + re-thrown on every later chart mount in the tab). + + Both come from pairing canvaskit.js glue from one canvaskit-wasm release with + canvaskit.wasm from another. Newer glue plus an older binary fails to link + (the binary declares an import the glue no longer supplies). Older glue plus a + newer binary is worse: it links, because the newer binary's imports are a + subset of what the old glue provides, but the glue then reads the exports + under stale minified names, the embind constructors never register, and + CanvasKitInit resolves with an object that has no classes at all. + LoadSkiaWeb stored that object on global.CanvasKit, Skia.web.ts wrapped it, + and the first draw threw from a worklet queue where no React error boundary + can reach it. The App side fixes the pairing itself (the binary is served + under a versioned URL); this patch is the safety net for tabs that still hit + a mismatch. + + LoadSkiaWeb now checks that the resolved module has its PictureRecorder and + Paint constructors before publishing it, throwing otherwise so the failure + surfaces inside WithSkiaWeb's lazy() and reaches the chart's error boundary. + It also clears ckSharedPromise when the init rejects or fails that check, so + the next mount gets a fresh attempt instead of the cached failure. + ``` + +- Upstream PR/issue: +- E/App issue: https://github.com/Expensify/App/issues/102042 +- PR introducing patch: https://github.com/Expensify/App/pull/102138 diff --git a/patches/howler/details.md b/patches/howler/details.md new file mode 100644 index 000000000000..d5ca9ce35e24 --- /dev/null +++ b/patches/howler/details.md @@ -0,0 +1,16 @@ +# `howler` patches + +### [howler+2.2.4+001+catch-audio-context-resume-rejection.patch](howler+2.2.4+001+catch-audio-context-resume-rejection.patch) + +- Reason: + + ``` + Howler calls `AudioContext.resume()` in the audio unlock handler and in `_autoResume()` without handling a rejected promise. + iOS Safari rejects it with `InvalidStateError: Failed to start the audio device` when it can't start the audio session + (for example, during a phone call or while another app holds the audio session), which surfaces as an unhandled promise rejection. + This patch adds no-op rejection handlers. Howler only sets its state to 'running' after a successful resume, so the next play() still retries. + ``` + +- Upstream PR/issue: https://github.com/goldfire/howler.js/issues/1743, https://github.com/goldfire/howler.js/pull/1764 +- E/App issue: https://github.com/Expensify/App/issues/102744 +- PR introducing patch: https://github.com/Expensify/App/pull/102795 diff --git a/patches/howler/howler+2.2.4+001+catch-audio-context-resume-rejection.patch b/patches/howler/howler+2.2.4+001+catch-audio-context-resume-rejection.patch new file mode 100644 index 000000000000..c782bc6f52a3 --- /dev/null +++ b/patches/howler/howler+2.2.4+001+catch-audio-context-resume-rejection.patch @@ -0,0 +1,25 @@ +diff --git a/node_modules/howler/dist/howler.js b/node_modules/howler/dist/howler.js +--- a/node_modules/howler/dist/howler.js ++++ b/node_modules/howler/dist/howler.js +@@ -382,7 +382,9 @@ + + // Calling resume() on a stack initiated by user gesture is what actually unlocks the audio on Android Chrome >= 55. + if (typeof self.ctx.resume === 'function') { +- self.ctx.resume(); ++ // The browser can refuse to start the audio device (for example, iOS Safari while another app holds the audio session). ++ // Swallow the rejection so it doesn't surface as an unhandled promise rejection. ++ self.ctx.resume().catch(function() {}); + } + + // Setup a timeout to check that we are unlocked on the next event loop. +@@ -529,7 +531,9 @@ + for (var i=0; igetRowsAffected(); - } catch (NitroSQLiteException& e) { -- sqliteExecuteLiteral(dbName, "ROLLBACK"); -+ // A failed ROLLBACK (SQLite may have already rolled back on its own) must not mask the original error. -+ try { -+ sqliteExecuteLiteral(dbName, "ROLLBACK"); -+ } catch (...) { -+ } - throw e; - } - } -@@ -61,7 +65,11 @@ SQLiteOperationResult sqliteExecuteBatch(const std::string& dbName, const std::v - .commands = (int)commandCount, - }; - } catch (NitroSQLiteException& e) { -- sqliteExecuteLiteral(dbName, "ROLLBACK"); -+ // A failed ROLLBACK (SQLite may have already rolled back on its own) must not mask the original error. -+ try { -+ sqliteExecuteLiteral(dbName, "ROLLBACK"); -+ } catch (...) { -+ } - throw e; - } - } diff --git a/patches/react-native-nitro-sqlite/react-native-nitro-sqlite+9.6.0+002+store-database-outside-documents.patch b/patches/react-native-nitro-sqlite/react-native-nitro-sqlite+9.6.0+002+store-database-outside-documents.patch deleted file mode 100644 index a98111df979c..000000000000 --- a/patches/react-native-nitro-sqlite/react-native-nitro-sqlite+9.6.0+002+store-database-outside-documents.patch +++ /dev/null @@ -1,95 +0,0 @@ -diff --git a/node_modules/react-native-nitro-sqlite/ios/OnLoad.mm b/node_modules/react-native-nitro-sqlite/ios/OnLoad.mm -index 6ce7258..65c29cb 100644 ---- a/node_modules/react-native-nitro-sqlite/ios/OnLoad.mm -+++ b/node_modules/react-native-nitro-sqlite/ios/OnLoad.mm -@@ -10,6 +10,63 @@ - using namespace margelo::nitro; - using namespace margelo::nitro::rnnitrosqlite; - -+// The Documents directory can be exposed to the user (Files app) when file sharing -+// is enabled, so databases are stored in Library/Application Support instead. -+// Databases created by older app versions are moved out of Documents on first launch. -+// -+// The "OnyxDB" name must match the database the app opens: DEFAULT_DB_NAME in -+// src/CONST/index.ts and DB_NAME in react-native-onyx's SQLiteProvider. A Jest test -+// (MoveFilesOutOfDocumentsTest) fails if the names drift apart. -+static void migrateDatabaseFiles(NSString *fromDirectory, NSString *toDirectory) { -+ NSFileManager *fileManager = [NSFileManager defaultManager]; -+ NSArray *databaseFiles = @[ @"OnyxDB", @"OnyxDB-wal", @"OnyxDB-shm" ]; -+ -+ if (![fileManager fileExistsAtPath:[fromDirectory stringByAppendingPathComponent:@"OnyxDB"]]) { -+ // Nothing to migrate. A previous run may have been interrupted after copying the set but -+ // before removing the journal files, so sweep any leftovers out of Documents. -+ [fileManager removeItemAtPath:[fromDirectory stringByAppendingPathComponent:@"OnyxDB-wal"] error:nil]; -+ [fileManager removeItemAtPath:[fromDirectory stringByAppendingPathComponent:@"OnyxDB-shm"] error:nil]; -+ return; -+ } -+ -+ // A database in Documents means an older build was writing there, so it is the live copy. -+ // Remove whatever sits at the destination (e.g. after a downgrade and re-upgrade) so a -wal -+ // from one database generation is never replayed into a database from another. -+ for (NSString *file in databaseFiles) { -+ [fileManager removeItemAtPath:[toDirectory stringByAppendingPathComponent:file] error:nil]; -+ } -+ -+ // Copy the whole set before deleting anything: committed-but-uncheckpointed writes live in -+ // the -wal file and SQLite only replays it when it sits next to its database, so the files -+ // must never be separated. If any copy fails, the intact originals in Documents keep being -+ // used (see the fallback in +load) and the migration retries on the next launch. -+ for (NSString *file in databaseFiles) { -+ NSString *sourcePath = [fromDirectory stringByAppendingPathComponent:file]; -+ if (![fileManager fileExistsAtPath:sourcePath]) { -+ continue; -+ } -+ -+ NSError *error = nil; -+ if (![fileManager copyItemAtPath:sourcePath toPath:[toDirectory stringByAppendingPathComponent:file] error:&error]) { -+ NSLog(@"Failed to migrate database file %@: %@", file, error.localizedDescription); -+ return; -+ } -+ } -+ -+ // The database file is deleted first, and the journals only once that succeeds: if the -+ // database cannot be removed, the existence check in +load keeps using the source database -+ // on this launch, so its -wal must stay next to it or committed writes would be lost. -+ // An interruption after the first delete can only leave journal files behind, which the -+ // sweep above removes on the next launch. -+ NSError *removeError = nil; -+ if (![fileManager removeItemAtPath:[fromDirectory stringByAppendingPathComponent:@"OnyxDB"] error:&removeError]) { -+ NSLog(@"Failed to remove migrated database from its old location: %@", removeError.localizedDescription); -+ return; -+ } -+ [fileManager removeItemAtPath:[fromDirectory stringByAppendingPathComponent:@"OnyxDB-wal"] error:nil]; -+ [fileManager removeItemAtPath:[fromDirectory stringByAppendingPathComponent:@"OnyxDB-shm"] error:nil]; -+} -+ - + (void)load { - // Get appGroupID value from Info.plist using key "AppGroup" - NSString *appGroupID = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"RNNitroSQLite_AppGroup"]; -@@ -30,9 +80,24 @@ - - documentPath = [storeUrl path]; - } else { -- // Get iOS app's document directory (to safely store database .sqlite3 file) -- NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true); -+ // Store databases in Library/Application Support, which is persistent, backed up, -+ // and never exposed to the user via the Files app (unlike the Documents directory) -+ NSArray *paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true); - documentPath = [paths objectAtIndex:0]; -+ -+ NSFileManager *fileManager = [NSFileManager defaultManager]; -+ if (![fileManager fileExistsAtPath:documentPath]) { -+ [fileManager createDirectoryAtPath:documentPath withIntermediateDirectories:YES attributes:nil error:nil]; -+ } -+ -+ NSString *documentsDirectory = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true) objectAtIndex:0]; -+ migrateDatabaseFiles(documentsDirectory, documentPath); -+ -+ // If the database could not be moved out of Documents, keep opening it there rather than -+ // creating a fresh empty one; the migration retries on the next launch. -+ if ([fileManager fileExistsAtPath:[documentsDirectory stringByAppendingPathComponent:@"OnyxDB"]]) { -+ documentPath = documentsDirectory; -+ } - } - - HybridNitroSQLite::docPath = [documentPath UTF8String]; diff --git a/patches/react-navigation/@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch b/patches/react-navigation/@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch new file mode 100644 index 000000000000..244e9ecc8a13 --- /dev/null +++ b/patches/react-navigation/@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch @@ -0,0 +1,48 @@ +diff --git a/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js b/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js +--- a/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js ++++ b/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js +@@ -48,6 +48,7 @@ function Card({ + gestureVelocityImpact = GESTURE_VELOCITY_IMPACT, + overlay = defaultOverlay, + animated, ++ active, + interpolationIndex, + opening, + closing, +@@ -375,7 +376,11 @@ function Card({ + children: /*#__PURE__*/_jsxs(Animated.View, { + pointerEvents: "box-none", + needsOffscreenAlphaCompositing: hasOpacityStyle(cardStyle), +- style: [styles.container, cardStyle], ++ style: [styles.container, { ++ // Avoid unfocused larger pages increasing scroll area ++ // e.g. when a smaller screen is pushed over a larger one on web ++ overflow: active ? undefined : 'hidden' ++ }, cardStyle], + children: [shadowEnabled && shadowStyle && !isTransparent ? /*#__PURE__*/_jsx(Animated.View, { + pointerEvents: "none", + style: [styles.shadow, gestureDirection === 'horizontal' ? [styles.shadowHorizontal, styles.shadowStart] : gestureDirection === 'horizontal-inverted' ? [styles.shadowHorizontal, styles.shadowEnd] : gestureDirection === 'vertical' ? [styles.shadowVertical, styles.shadowTop] : [styles.shadowVertical, styles.shadowBottom], { +diff --git a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js +--- a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js ++++ b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js +@@ -22,9 +22,6 @@ export const CardA11yWrapper = /*#__PURE__*/React.forwardRef(({ + "aria-hidden": !focused, + pointerEvents: (animated ? inert : !focused) ? 'none' : 'box-none', + style: [StyleSheet.absoluteFill, { +- // This is necessary to avoid unfocused larger pages increasing scroll area +- // The issue can be seen on the web when a smaller screen is pushed over a larger one +- overflow: active ? undefined : 'hidden', + // We use visibility on web + display: Platform.OS !== 'web' && isHidden ? 'none' : 'flex', + zIndex: Platform.OS === 'web' ? 'auto' : undefined, +diff --git a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardContainer.js b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardContainer.js +--- a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardContainer.js ++++ b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardContainer.js +@@ -198,6 +198,7 @@ function CardContainerInner({ + detachCurrentScreen: detachCurrentScreen, + children: /*#__PURE__*/_jsx(Card, { + animated: animated, ++ active: active, + interpolationIndex: interpolationIndex, + gestureDirection: gestureDirection, + layout: layout, diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 7e5f8c7caa01..955b43b2c86b 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -67,3 +67,14 @@ - Upstream PR/issue: https://github.com/react-navigation/react-navigation/issues/11145 - E/App issue: [#94571](https://github.com/Expensify/App/issues/94571) - PR Introducing Patch: [#95980](https://github.com/Expensify/App/pull/95980) + +### [@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch](@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch) + +- Reason: On the web floating RHP each stacked card draws its own drop shadow through the interpolator's `cardStyle` (`getRHPExtendedCardFrame`). `CardA11yWrapper` puts `overflow: hidden` on every card that is not on top, and the wrapper is sized to the navigator frame, so it clips the shadow of the card under the focused one. Opening an expense over an expense report cut the report card's left and bottom shadow. The patch moves that clip from `CardA11yWrapper` to the `Animated.View` in `Card` that receives `cardStyle`. That view paints the shadow, and `overflow: hidden` does not clip an element's own shadow, so the shadow stays and the content is still clipped. `CardContainer` passes `active` down to `Card`. +- Upstream PR/issue: https://github.com/react-navigation/react-navigation/issues/13254, fix in https://github.com/react-navigation/react-navigation/pull/13255. Upstream declined an opt-out option and asked for this instead. The patch is that change applied to the compiled 7.8.5 output. +- E/App issue: N/A, the patch came out of the internal web floating-RHP styling work. Removing it is tracked in [#101715](https://github.com/Expensify/App/issues/101715), the react-navigation upgrade. +- PR Introducing Patch: [#101093](https://github.com/callstack-internal/Expensify-App/pull/101093) +- PR Updating Patch: N/A +- Note: Not gated to the RHP. Every stack now clips inactive cards one level lower, so the built-in `styles.shadow` edge view inside `Card` is clipped on inactive cards too. The active card covers that edge. +- Note: Upstream shipped the fix in `@react-navigation/stack` 7.11.2, first release containing https://github.com/react-navigation/react-navigation/pull/13255. Their shape differs from ours: `CardA11yWrapper` drops `overflow` completely, and `CardContainer` passes `pageOverflowEnabled: active && headerMode !== 'float' && presentation !== 'modal'`, so the clip lands in `CardContent`, whose `styles.card` still carries `overflow: 'hidden'`. Inactive cards stay clipped and the stacked RHP shadow survives, but the clip sits one level below ours, which leaves the built-in `styles.shadow` edge view unclipped on inactive cards. +- Note: Drop this patch by moving to `stack` 7.11.2 or newer, see #101715 above. It cannot be bumped on its own: `createStackNavigator` there reads `render` from `useNavigationBuilder`, which starts in `core` 7.22.1, and `elements` 2.9.43 plus every sibling declare `native ^7.4.1` as a peer, so npm rejects 7.11.2 next to our `native` 7.1.33. `stack+001+edge-drag-gesture` and `stack+002+dontDetachScreen` need hand-porting to 7.11.2 because it rewrote the gesture and animation lifecycle in `Card.js` and switched the gesture wrapper to `pointerEvents: "auto"`. diff --git a/scripts/createOrUpdateDeployChecklist.ts b/scripts/createOrUpdateDeployChecklist.ts index a109693e1246..d32bb5102139 100644 --- a/scripts/createOrUpdateDeployChecklist.ts +++ b/scripts/createOrUpdateDeployChecklist.ts @@ -229,7 +229,8 @@ async function run(): Promise { const {mergedPRs: mergedPREntries, submoduleUpdates} = await GitUtils.getMergedPRsDeployedBetween(previousChecklistData.tag, newStagingTag, CONST.APP_REPO); const mergedPRs = mergedPREntries.map((pr) => pr.prNumber).sort((a, b) => a - b); - const previousPRNumbers = new Set(previousChecklistData.PRList.map((pr) => pr.number)); + // Previous PRs should include PRs and internalQA items + const previousPRNumbers = new Set([...previousChecklistData.PRList, ...previousChecklistData.internalQAPRList].map((pr) => pr.number)); const previousMobileExpensifyPRNumbers = new Set(previousChecklistData.PRListMobileExpensify.map((pr) => pr.number)); core.startGroup('Filtering PRs:'); core.info('mergedPRs includes cherry-picked PRs that have already been released with previous checklist, so we need to filter these out'); diff --git a/src/App.tsx b/src/App.tsx index 7c382d815219..8a1ed222ff7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import {CurrencyListContextProvider} from './components/CurrencyListContextProvi import {CurrentUserPersonalDetailsProvider} from './components/CurrentUserPersonalDetailsProvider'; import CustomStatusBarAndBackground from './components/CustomStatusBarAndBackground'; import CustomStatusBarAndBackgroundContextProvider from './components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider'; +import {EditingCellProvider} from './components/EditableCell'; import EnvironmentProvider from './components/EnvironmentContextProvider'; import ErrorBoundary from './components/ErrorBoundary'; import FullScreenBlockingViewContextProvider from './components/FullScreenBlockingViewContextProvider'; @@ -38,7 +39,6 @@ import SVGDefinitionsProvider from './components/SVGDefinitionsProvider'; import ThemeIllustrationsProvider from './components/ThemeIllustrationsProvider'; import ThemeProvider from './components/ThemeProvider'; import ThemeStylesProvider from './components/ThemeStylesContextProvider'; -import {EditingCellProvider} from './components/TransactionItemRow/EditableCell'; import {KeyboardStateProvider} from './components/withKeyboardState'; import CONFIG from './CONFIG'; import CONST from './CONST'; diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b5abc0f4084b..69d557570e35 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -225,6 +225,13 @@ const RESERVATION_TYPE = { TRAIN: 'train', } as const; +/** Every event that can fire a rule, across all rule kinds. Hoisted so `RULES` can list subsets of it. */ +const RULE_TRIGGERS = { + REPORT_SUBMIT: 'ReportSubmit', + REPORT_APPROVE: 'ReportApprove', + CREATE_TRANSACTION: 'CreateTransaction', +} as const; + const EMAIL = { ACCOUNTING: 'accounting@expensify.com', ACCOUNTS_PAYABLE: 'accountspayable@expensify.com', @@ -1130,11 +1137,11 @@ const CONST = { CAMPFIRE: 'campfire', BUSINESS_CENTRAL: 'businessCentral', COMMUTER_EXCLUSIONS: 'commuterExclusions', + COMMUTER_EXCLUSIONS_ARRANGEMENTS: 'commuterExclusionsArrangements', MULTIPLE_APPROVERS: 'multipleApprovers', GLOBAL_REIMBURSEMENTS: 'globalReimbursements', GLOBAL_REIMBURSEMENT_FX: 'globalReimbursementFX', DEFAULT_LETTER_AVATARS: 'defaultLetterAvatars', - NETSUITE_OAUTH: 'netSuiteOAuth', TRAVEL_CODING_SYNC: 'travelCodingSync', CONCIERGE_RESPOND_IN_THREAD: 'conciergeRespondInThread', ARCHIVE_POLICIES: 'archivePolicies', @@ -1143,6 +1150,7 @@ const CONST = { INSIGHTS_PAGE: 'insightsPage', INSIGHTS_COMPARE: 'insightsCompare', PAYMENT_HISTORY: 'paymentHistory', + ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns', }, BUTTON_STATES: { DEFAULT: 'default', @@ -1738,6 +1746,7 @@ const CONST = { ACTIONABLE_MENTION_INVITE_TO_SUBMIT_EXPENSE_CONFIRM_WHISPER: 'ACTIONABLEMENTIONINVITETOSUBMITEXPENSECONFIRMWHISPER', ACTIONABLE_REPORT_MENTION_WHISPER: 'ACTIONABLEREPORTMENTIONWHISPER', ACTIONABLE_TRACK_EXPENSE_WHISPER: 'ACTIONABLETRACKEXPENSEWHISPER', + AGENT_PROMPT_UPDATED: 'AGENTPROMPTUPDATED', POLICY_EXPENSE_CHAT_WELCOME_WHISPER: 'POLICYEXPENSECHATWELCOMEWHISPER', ADD_COMMENT: 'ADDCOMMENT', @@ -1796,6 +1805,7 @@ const CONST = { MOVED: 'MOVED', MOVED_TRANSACTION: 'MOVEDTRANSACTION', UNREPORTED_TRANSACTION: 'UNREPORTEDTRANSACTION', + UNDELETED_TRANSACTION: 'UNDELETEDTRANSACTION', OUTDATED_BANK_ACCOUNT: 'OUTDATEDBANKACCOUNT', // OldDot Action REIMBURSED: 'REIMBURSED', REIMBURSEMENT_ACH_BOUNCE: 'REIMBURSEMENTACHBOUNCE', // OldDot Action @@ -1916,6 +1926,7 @@ const CONST = { UPDATE_FEATURE_ENABLED: 'POLICYCHANGELOG_UPDATE_FEATURE_ENABLED', UPDATE_IS_ATTENDEE_TRACKING_ENABLED: 'POLICYCHANGELOG_UPDATE_IS_ATTENDEE_TRACKING_ENABLED', UPDATE_REQUIRE_COMPANY_CARDS_ENABLED: 'POLICYCHANGELOG_UPDATE_REQUIRE_COMPANY_CARDS_ENABLED', + UPDATE_AUTO_CATEGORIZE_NEW_EXPENSES: 'POLICYCHANGELOG_UPDATE_AUTO_CATEGORIZE_NEW_EXPENSES', UPDATE_REQUIRES_CATEGORY: 'POLICYCHANGELOG_UPDATE_REQUIRES_CATEGORY', UPDATE_REQUIRES_TAG: 'POLICYCHANGELOG_UPDATE_REQUIRES_TAG', UPDATE_GLOBAL_REIMBURSEMENTS_FX_PREFERENCE: 'POLICYCHANGELOG_UPDATE_GLOBAL_REIMBURSEMENTS_FX_PREFERENCE', @@ -1941,6 +1952,7 @@ const CONST = { UPDATE_REIMBURSER: 'POLICYCHANGELOG_UPDATE_REIMBURSER', UPDATE_PROHIBITED_EXPENSES: 'POLICYCHANGELOG_UPDATE_PROHIBITED_EXPENSES', UPDATE_COMMUTER_EXCLUSIONS: 'POLICYCHANGELOG_UPDATE_COMMUTER_EXCLUSIONS', + UPDATE_MEMBER_WORK_ARRANGEMENT: 'POLICYCHANGELOG_UPDATE_MEMBER_WORK_ARRANGEMENT', UPDATE_REIMBURSEMENT_CHOICE: 'POLICYCHANGELOG_UPDATE_REIMBURSEMENT_CHOICE', UPDATE_REIMBURSEMENT_ENABLED: 'POLICYCHANGELOG_UPDATE_REIMBURSEMENT_ENABLED', UPDATE_REPORT_FIELD: 'POLICYCHANGELOG_UPDATE_REPORT_FIELD', @@ -2311,10 +2323,13 @@ const CONST = { BREADCRUMB_MEMORY_PERIODIC: 'Periodic memory check', BREADCRUMB_MEMORY_FOREGROUND: 'App foreground - memory check', TAGS: { + APP_BLOCKING: 'app_blocking', ACTIVE_POLICY: 'active_policy_id', POLICIES_COUNT: 'policies_count', REPORTS_COUNT: 'reports_count', PERSONAL_DETAILS_COUNT: 'personal_details_count', + TRANSACTIONS_COUNT: 'transactions_count', + DB_SIZE: 'db_size', USER_ROLE: 'user_role', NUDGE_MIGRATION_COHORT: 'nudge_migration_cohort', AUTHENTICATION_FUNCTION: 'authentication_function', @@ -2347,6 +2362,13 @@ const CONST = { INDEXED_DB: 'indexed_db', UNAVAILABLE: 'unavailable', }, + // Numeric prefix keeps Sentry's alphabetical sort in size order + SIZE_TIER: { + SMALL: '1-small', + MEDIUM: '2-medium', + LARGE: '3-large', + XLARGE: '4-xlarge', + }, BUILD_TYPE_HYBRID_APP: 'hybrid_app', BUILD_TYPE_STANDALONE: 'standalone', // Span names @@ -2720,6 +2742,7 @@ const CONST = { EXP_ERROR: 666, UNABLE_TO_RETRY: 'unableToRetry', UPDATE_REQUIRED: 426, + TOO_MANY_REQUESTS: 429, INCORRECT_VALIDATE_CODE: 451, ADMIN_REQUIRED: 460, SERVICE_UNAVAILABLE: 503, @@ -3086,6 +3109,16 @@ const CONST = { MAX_FILE_LIMIT_EXCEEDED: 'maxFileLimitExceeded', }, + INPUT_VALIDATION_ERRORS: { + REQUIRED: 'required', + EXISTING: 'existing', + INVALID: 'invalid', + TOO_LONG: 'tooLong', + NOT_INTEGER: 'notInteger', + TOO_HIGH: 'tooHigh', + TOO_LOW: 'tooLow', + }, + IOS_CAMERA_ROLL_ACCESS_ERROR: 'Access to photo library was denied', EMOJI_PICKER_ITEM_TYPES: { HEADER: 'header', @@ -3971,6 +4004,32 @@ const CONST = { SYNC_TAX_RATES: 'syncTaxRates', SYNC_ITEMS: 'syncItems', FIELD_MAPPING_PREFIX: 'fieldMapping_', + EXPORTER: 'exporter', + EXPORT_DATE: 'exportDate', + REIMBURSABLE: 'reimbursable', + NON_REIMBURSABLE: 'nonReimbursable', + REIMBURSABLE_ACCOUNT: 'reimbursableAccount', + NON_REIMBURSABLE_ACCOUNT: 'nonReimbursableAccount', + DEFAULT_VENDOR_ID: 'defaultVendorID', + PAYMENT_METHOD_CODE: 'paymentMethodCode', + }, + + BUSINESS_CENTRAL_EXPORT_DATE: { + LAST_EXPENSE: 'LAST_EXPENSE', + REPORT_EXPORTED: 'REPORT_EXPORTED', + REPORT_SUBMITTED: 'REPORT_SUBMITTED', + }, + + /** Business Central document an expense exports to */ + BUSINESS_CENTRAL_EXPORT_DESTINATION: { + JOURNAL_ENTRY: 'JOURNAL_ENTRY', + PURCHASE_INVOICE: 'PURCHASE_INVOICE', + }, + + /** Whether an exported Business Central document is only created or also posted */ + BUSINESS_CENTRAL_POSTING_MODE: { + CREATE_ONLY: 'CREATE_ONLY', + CREATE_AND_POST: 'CREATE_AND_POST', }, BUSINESS_CENTRAL_MAPPING_VALUE: { @@ -4967,6 +5026,12 @@ const CONST = { BUSINESS_CENTRAL_SYNC_IMPORT_DATA: 'businessCentralSyncImportData', }, SYNC_STAGE_TIMEOUT_MINUTES: 20, + /** How many days before a QuickBooks Online refresh token expires the workspace starts warning admins to reconnect */ + QBO_REFRESH_TOKEN_EXPIRY_WARNING_DAYS: 7, + QBO_REFRESH_TOKEN_EXPIRY_STATUS: { + EXPIRING_SOON: 'expiringSoon', + EXPIRED: 'expired', + }, }, ACCESS_VARIANTS: { PAID: 'paid', @@ -5120,7 +5185,7 @@ const CONST = { // Corner radius scaled to the avatar size, used for workspace avatars ROUNDED_SQUARE: 'rounded-square', }, - + CASH_CARD_NAME: '__CASH__', COMPANY_CARD: { // Mostly used for feed details FEED_BANK_NAME: { @@ -5545,6 +5610,14 @@ const CONST = { SUBSCRIPTION: { TEAM_2025_PRICING_START_DATE: new Date(2025, 3, 1), PRICING_TYPE_2025: 'team2025Pricing', + EARLY_RENEWAL: { + NON_INCENTIVIZED_START: '2026-10-01T00:00:00Z', + INCENTIVIZED_START: '2026-10-15T00:00:00Z', + CAMPAIGN_END: '2027-01-01T00:00:00Z', + OFFER_ID: { + NON_INCENTIVIZED_ONE_YEAR: 'nonIncentivizedOneYear', + }, + }, TYPE: { ANNUAL: 'yearly2018', PAY_PER_USE: 'monthly2018', @@ -7157,6 +7230,7 @@ const CONST = { RHP_HOME_PAGE: 'rhpHomePage', TRACK_EXPENSES_WITH_CONCIERGE: 'trackExpensesWithConcierge', INBOX_ADMINS_BESPOKE: 'inboxAdminsBespoke', + HOME_PAGE_NO_RHP: 'homePageNoRHP', }, ONBOARDING_JOINABLE_WORKSPACES_LIMIT: 5, ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE: 'What would you like to do with this expense?', @@ -7246,6 +7320,7 @@ const CONST = { SESSION_STORAGE_KEYS: { INITIAL_URL: 'INITIAL_URL', RETRY_LAZY_REFRESHED: 'RETRY_LAZY_REFRESHED', + UPDATE_REQUIRED_RELOADED_VERSION: 'UPDATE_REQUIRED_RELOADED_VERSION', LAST_REFRESH_TIMESTAMP: 'LAST_REFRESH_TIMESTAMP', LAST_VISITED_PATH: { WORKSPACES_TAB: 'LAST_VISITED_PATH_WORKSPACES_TAB', @@ -8002,6 +8077,7 @@ const CONST = { EXPORTER: 'exporter', CATEGORY: 'category', TAG: 'tag', + VENDOR: 'vendor', TAX_RATE: 'taxRate', CARD_ID: 'cardID', FEED: 'feed', @@ -8053,6 +8129,7 @@ const CONST = { TAG_EMPTY_VALUE: 'none', CATEGORY_EMPTY_VALUE: 'none', CATEGORY_DEFAULT_VALUE: 'Uncategorized', + VENDOR_EMPTY_VALUE: 'none', MERCHANT_EMPTY_VALUE: 'none', SEARCH_ROUTER_ITEM_TYPE: { CONTEXTUAL_SUGGESTION: 'contextualSuggestion', @@ -8089,6 +8166,7 @@ const CONST = { EXPORTER: 'exporter', CATEGORY: 'category', TAG: 'tag', + VENDOR: 'vendor', TAX_RATE: 'tax-rate', CARD_ID: 'card', FEED: 'feed', @@ -8250,7 +8328,7 @@ const CONST = { MAX_STALE_HOLD_DURATION: 500, }, TODO_BADGE_MAX_COUNT: 50, - TOP_SEARCH_LIMIT: 10, + TOP_SEARCH_LIMIT: 5, }, SEARCH_SELECTOR: { SELECTION_MODE_SINGLE: 'single', @@ -8722,16 +8800,33 @@ const CONST = { RULES: { SCOPE: { POLICY: 'policy', + ACCOUNT: 'account', + }, + TRIGGERS: RULE_TRIGGERS, + /** Every action a rule can perform, across all rule kinds. */ + ACTIONS: { + FORWARD_TO: 'ForwardTo', + APPROVE_REPORT: 'ApproveReport', + SET: 'Set', }, APPROVAL_WORKFLOW: { - TRIGGER: { - REPORT_SUBMIT: 'ReportSubmit', - REPORT_APPROVE: 'ReportApprove', - }, - ACTION: { - FORWARD_TO: 'ForwardTo', - APPROVE_REPORT: 'ApproveReport', + /** A rule firing only on these is an approval workflow rather than an expense default. */ + TRIGGERS: [RULE_TRIGGERS.REPORT_SUBMIT, RULE_TRIGGERS.REPORT_APPROVE], + }, + EXPENSE_DEFAULT: { + /** Expense fields a `Set` action can write to */ + FIELD: { + BILLABLE: 'billable', + CATEGORY: 'category', + COMMENT: 'comment', + MERCHANT: 'merchant', + REIMBURSABLE: 'reimbursable', + TAG: 'tag', + TAX: 'tax', + VENDOR_ID: 'vendorID', }, + /** Every expense default rule is created with the same priority, per the rules engine spec */ + PRIORITY: 10000, }, }, @@ -8774,6 +8869,11 @@ const CONST = { EXTERNAL_ID: 'externalID', MAX_AMOUNT_NO_RECEIPT: 'maxAmountNoReceipt', MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 'maxAmountNoItemizedReceipt', + PAYROLL_CODE: 'payrollCode', + ARE_COMMENTS_REQUIRED: 'areCommentsRequired', + COMMENT_HINT: 'commentHint', + EXPENSE_LIMIT_TYPE: 'expenseLimitType', + MAX_EXPENSE_AMOUNT: 'maxExpenseAmount', MERCHANT_IS: 'merchantIs', MERCHANT_CONTAINS: 'merchantContains', UPDATED_MERCHANT: 'updatedMerchant', @@ -9072,8 +9172,28 @@ const CONST = { OFFLINE_INDICATOR_HEIGHT: 25, BILLING: { + TYPE_FAILED: 'failed', TYPE_FAILED_2018: 'failed_2018', + TYPE_FAILED_SMARTSCAN: 'failed_smartscan2018', TYPE_STRIPE_FAILED_AUTHENTICATION: 'failed_stripe_authentication', + TYPE_CLEAR: 'clear', + TYPE_REFUND: 'refund', + TYPE_CC_REFUND: 'ccRefund', + TYPE_DISPUTE: 'dispute', + TYPE_TRANSFER: 'transfer', + TYPE_TRANSFER_TO: 'transfer_to', + TYPE_TRANSFER_FAILED: 'transfer_failed', + TYPE_TRANSFER_OLD: 'transfer_old', + }, + PAYMENT_HISTORY: { + STATE: { + PAID: 'paid', + CLEARED: 'cleared', + FAILED: 'failed', + REFUNDED: 'refunded', + DISPUTED: 'disputed', + BALANCE_TRANSFER: 'balanceTransfer', + }, }, ONBOARDING_HELP: { @@ -9136,6 +9256,18 @@ const CONST = { MULTI_SELECT: 'multiSelect', }, + /** Where the column header is rendered. The placements are exclusive, so the table is always in exactly one of them. */ + COLUMN_HEADER_PLACEMENT: { + /** The table has no column header anywhere. */ + NONE: 'none', + /** A direct child of the table container, outside the list. Where every table without a page header keeps it. */ + OUTSIDE_LIST: 'outsideList', + /** A synthetic list row, which FlashList paints as a sticky overlay outside the scroller. */ + STICKY_ROW: 'stickyRow', + /** In flow inside the list header, so the scroller carries it sideways with the columns it labels. */ + LIST_HEADER: 'listHeader', + }, + DYNAMIC_COLUMNS: { /** How many of the longest strings are measured per column, since character count only approximates rendered width. */ MEASURED_CANDIDATES_PER_COLUMN: 5, @@ -9149,6 +9281,9 @@ const CONST = { }, SENTRY_LABEL: { + TEST_TOOL_MENU: { + SERVER: 'TestToolMenu-Server', + }, BILLING_BANNER: { RIGHT_ICON: 'BillingBanner-RightIcon', }, @@ -9333,6 +9468,7 @@ const CONST = { CONTROL_WORKSPACE: 'Insights-ControlWorkspace', CONTROL_GROUP_CURRENCY: 'Insights-ControlGroupCurrency', CONTROL_GROUP_BY: 'Insights-ControlGroupBy', + CONTROL_COMPARE: 'Insights-ControlCompare', }, EXPENSE_RULES: { TABLE_ROW: 'ExpenseRules-TableRow', @@ -9807,6 +9943,7 @@ const CONST = { AGENT_RULE_ITEM: 'WorkspaceRules-AgentRuleItem', ADD_AGENT_RULE: 'WorkspaceRules-AddAgentRule', SUGGESTED_AGENT_RULE: 'WorkspaceRules-SuggestedAgentRule', + SUGGESTED_AGENT_RULE_CATEGORY: 'WorkspaceRules-SuggestedAgentRuleCategory', AGENT_RULE_DELETE: 'WorkspaceRules-AgentRuleDelete', NEW_RULE_MENU_ITEM: 'WorkspaceRules-NewRuleMenuItem', NEW_RULE_MENU_ITEM_RESTRICT_CARD_SPEND: 'WorkspaceRules-NewRuleMenuItem-RestrictCardSpend', @@ -10005,6 +10142,7 @@ const CONST = { RETRY_PAYMENT: 'SettingsSubscription-RetryPayment', AUTHENTICATE_PAYMENT: 'SettingsSubscription-AuthenticatePayment', VIEW_PAYMENT_HISTORY: 'SettingsSubscription-ViewPaymentHistory', + PAYMENT_HISTORY_ROW: 'SettingsSubscription-PaymentHistoryRow', REQUEST_REFUND: 'SettingsSubscription-RequestRefund', CANCEL_SUBSCRIPTION: 'SettingsSubscription-CancelSubscription', }, diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 8ed1e7ba0604..704fa8b8ed02 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -8,6 +8,7 @@ import type {Route} from './ROUTES'; import CONST from './CONST'; import useIsAuthenticated from './hooks/useIsAuthenticated'; import useOnyx from './hooks/useOnyx'; +import {usePersonalDetailsByIDs} from './hooks/usePersonalDetails'; import {openReportFromDeepLink} from './libs/actions/Link'; import * as Report from './libs/actions/Report'; import {hasAuthToken, isAnonymousUser} from './libs/actions/Session'; @@ -17,6 +18,7 @@ import {endSpan} from './libs/telemetry/activeSpans'; import {hasSecureLinkKey} from './libs/Url'; import ONYXKEYS from './ONYXKEYS'; import {guidedSetupAndTourStatusSelector} from './selectors/Onboarding'; +import {isEmptyObject} from './types/utils/EmptyObject'; import isLoadingOnyxValue from './types/utils/isLoadingOnyxValue'; type DeepLinkHandlerProps = { @@ -45,12 +47,20 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { const [introSelected, introSelectedMetadata] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [guidedSetupAndTourStatus, guidedSetupAndTourStatusMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const [, betasMetadata] = useOnyx(ONYXKEYS.BETAS); + // Only Concierge's personal detail is needed to create the Concierge chat when a deep link points to a missing report. + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); + // Read through a ref so a Concierge personal detail update (e.g. OpenApp loading it) does not re-run deep link handling. + const conciergePersonalDetailsRef = useRef(conciergePersonalDetails); const isAuthenticated = useIsAuthenticated(); useEffect(() => { reportNameValuePairsRef.current = reportNameValuePairs; }, [reportNameValuePairs]); + useEffect(() => { + conciergePersonalDetailsRef.current = conciergePersonalDetails; + }, [conciergePersonalDetails]); + // An anonymous deep link into a public room needs to be re-fetched after OpenApp settles (see the effect // below). Track the pending reportID so both the initial-URL and the url-change paths stay in sync. const trackPendingPublicRoomFromDeepLink = useCallback((url: string, isCurrentlyAuthenticated: boolean) => { @@ -121,6 +131,9 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { if (introSelected === undefined) { Log.info('[Deep link] introSelected is undefined when processing initial URL', false, {url}); } + if (isEmptyObject(conciergePersonalDetailsRef.current)) { + Log.info('[Deep link] Concierge personal details are empty when processing initial URL', false, {url}); + } openReportFromDeepLink( url, allReports, @@ -130,6 +143,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { guidedSetupAndTourStatus?.isSelfTourViewed, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, reportNameValuePairsRef.current, + conciergePersonalDetailsRef.current, ); trackPendingPublicRoomFromDeepLink(url, isCurrentlyAuthenticated); } else { @@ -157,6 +171,9 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { if (introSelected === undefined) { Log.info('[Deep link] introSelected is undefined when processing URL change', false, {url: state.url}); } + if (isEmptyObject(conciergePersonalDetailsRef.current)) { + Log.info('[Deep link] Concierge personal details are empty when processing URL change', false, {url: state.url}); + } const isCurrentlyAuthenticated = hasAuthToken(); // A Submit-via-PDF secure access link can arrive while the app is already running (warm), where // getInitialURL() is empty. Record it so onboarding suppression has a session-sticky signal, the same @@ -173,6 +190,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { guidedSetupAndTourStatus?.isSelfTourViewed, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, reportNameValuePairsRef.current, + conciergePersonalDetailsRef.current, ); trackPendingPublicRoomFromDeepLink(state.url, isCurrentlyAuthenticated); }); diff --git a/src/GlobalModals.tsx b/src/GlobalModals.tsx index 9e6a7b48a156..d7235959d289 100644 --- a/src/GlobalModals.tsx +++ b/src/GlobalModals.tsx @@ -2,8 +2,10 @@ import React, {startTransition, useEffect, useState} from 'react'; import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider'; import EmojiPicker from './components/EmojiPicker/EmojiPicker'; +import ExpenseAddedGrowl from './components/ExpenseAddedGrowl'; import GrowlNotification from './components/GrowlNotification'; import LazyModalSlot from './components/LazyModalSlot'; +import useIsAuthenticated from './hooks/useIsAuthenticated'; import * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; import {growlRef} from './libs/Growl'; import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu'; @@ -22,6 +24,7 @@ const IDLE_CALLBACK_TIMEOUT_MS = 2000; function GlobalModals() { const [shouldRenderContextMenu, setShouldRenderContextMenu] = useState(false); const [shouldRenderDeferredModals, setShouldRenderDeferredModals] = useState(false); + const isAuthenticated = useIsAuthenticated(); // Defer loading the context menu and rare-condition modals until after startup to avoid // pulling in their dependencies (ContextMenuActions, ReportUtils, ModifiedExpenseMessage, @@ -48,6 +51,7 @@ function GlobalModals() { return ( <> + {isAuthenticated && } {shouldRenderContextMenu && ( diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index aa1fffc6b9c9..5fa8917f1f3b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -14,6 +14,7 @@ import type {Attendee, DistanceExpenseType, Participant} from './types/onyx/IOU' import type Onboarding from './types/onyx/Onboarding'; import type {AnyOnyxUpdate} from './types/onyx/Request'; import type {SavedCSVColumnLayoutList} from './types/onyx/SavedCSVColumnLayout'; +import type {SearchDataTypes} from './types/onyx/SearchResults'; import type AssertTypesEqual from './types/utils/AssertTypesEqual'; import type DeepValueOf from './types/utils/DeepValueOf'; @@ -46,9 +47,15 @@ const ONYXKEYS = { /** Whether a semantic modal is covering the product marketing window in this app instance */ RAM_ONLY_IS_PRODUCT_MARKETING_WINDOW_COVERED: 'isProductMarketingWindowCovered', + /** Whether the current user can claim or nudge a billing owner about the Early Renewal 2027 offer */ + EARLY_RENEWAL_OFFER_ELIGIBILITY: 'earlyRenewalOfferEligibility', + /** Boolean flag set whenever we are searching for reports in the server */ RAM_ONLY_IS_SEARCHING_FOR_REPORTS: 'isSearchingForReports', + /** Ordered reportIDs from the latest SearchForReports response, used to display server search results in the tier order Auth returned. */ + RAM_ONLY_SEARCH_RESULT_REPORT_IDS: 'searchResultReportIDs', + /** Pagination state for tag filter search (hasMore, nextCursor). RAM-only so it resets on app restart. */ RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION: 'searchTagFiltersPagination', @@ -475,6 +482,10 @@ const ONYXKEYS = { IS_LOADING_APP: 'isLoadingApp', + /** Set when we are switching to a delegator */ + IS_SWITCHING_TO_DELEGATOR: 'isSwitchingToDelegator', + + /** Is loading policy rules preview? */ IS_LOADING_POLICY_CODING_RULES_PREVIEW: 'isLoadingPolicyCodingRulesPreview', /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ @@ -495,9 +506,24 @@ const ONYXKEYS = { /** Set whether the search filters category data has loaded */ IS_SEARCH_FILTERS_CATEGORY_DATA_LOADED: 'isSearchFiltersCategoryDataLoaded', + /** + * Set once `GetRules` has answered, so screens that only consume the rules collection fetch it once. + * RAM only, so a new session fetches again rather than trusting a collection another device has since changed. + */ + RAM_ONLY_HAS_RULES_DATA_BEEN_FETCHED: 'hasRulesDataBeenFetched', + + /** Set while a `GetRules` request is in flight, so screens can wait for it instead of reading an empty collection */ + RAM_ONLY_IS_LOADING_RULES: 'isLoadingRules', + /** Set while search filter category data is loading */ RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA: 'isLoadingSearchFiltersCategoryData', + /** Set whether the search filters vendor data has loaded */ + IS_SEARCH_FILTERS_VENDOR_DATA_LOADED: 'isSearchFiltersVendorDataLoaded', + + /** Set while search filter vendor data is loading */ + RAM_ONLY_IS_LOADING_SEARCH_FILTERS_VENDOR_DATA: 'isLoadingSearchFiltersVendorData', + HAS_LOADED_APP: 'hasLoadedApp', IS_TEST_TOOLS_MODAL_OPEN: 'isTestToolsModalOpen', @@ -742,7 +768,7 @@ const ONYXKEYS = { /** Caches converted footer-total amounts (by transaction and by whole-search query, nested by currency) for the Search footer currency picker */ SEARCH_FOOTER_CONVERSION: 'searchFooterConversion', - /** Maps each loaded search snapshot's hash to its original query string, used to fan optimistic IOU updates to every matching snapshot */ + /** @deprecated Use `search.inputQuery` on the snapshot. Only read by the MoveSearchQueryByHashToSnapshots migration. */ SEARCH_QUERY_BY_HASH: 'searchQueryByHash', /** Stores recently used currencies */ @@ -850,6 +876,10 @@ const ONYXKEYS = { /** List of transaction IDs used when navigating to prev/next transaction when viewing it in RHP */ TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS: 'transactionThreadNavigationTransactionIDs', + /** Hash of the search snapshot that holds the transactions referenced by TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS. + * Used to fall back to snapshot data when the live transaction collection hasn't loaded those transactions yet (e.g. opening an expense from the Spend page as an approver). */ + TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH: 'transactionThreadNavigationSnapshotHash', + /** Optional map of transactionID -> sibling descriptor for prev/next navigation in snapshot-backed flows (e.g. Home "Recently added"), where siblings may be absent from the main Onyx collections. When set, navigation resolves (and lazily creates) each sibling's thread on demand from its descriptor. */ TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS: 'transactionThreadNavigationThreadReportIDs', @@ -884,8 +914,8 @@ const ONYXKEYS = { /** Whether the user has denied the contact import permission prompt */ HAS_DENIED_CONTACT_IMPORT_PROMPT: 'hasDeniedContactImportPrompt', - /** The transaction IDs to be highlighted when opening the Expenses search route page */ - TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute', + /** Maps each newly-added transaction ID to its search data type, flagging it for the "Expense added" growl */ + RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS: 'expenseAddedGrowlTransactionIDs', /** The report ID to be highlighted when returning to the workspace rooms page */ ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE: 'roomIDHighlightOnRoomsPage', @@ -946,6 +976,12 @@ const ONYXKEYS = { /** Session-scoped loading flags for the Expensify Card page, keyed by policyID. * Registered as RAM-only in `setup/index.ts`. */ RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE: 'expensifyCardLoadingState_', + /** Session-scoped loading flags for a policy's on-demand categories read, keyed by policyID. + * Registered as RAM-only in `setup/index.ts`. */ + RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE: 'policyCategoriesLoadingState_', + /** Session-scoped loading flags for a policy's on-demand tags read, keyed by policyID. + * Registered as RAM-only in `setup/index.ts`. */ + RAM_ONLY_POLICY_TAGS_LOADING_STATE: 'policyTagsLoadingState_', /** Pagination cursors for a report's action list. */ REPORT_PAGINATION_STATE: 'reportPaginationState_', REPORT_ACTIONS: 'reportActions_', @@ -1366,6 +1402,8 @@ const ONYXKEYS = { RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', LOGIN_TO_ACCOUNT_ID_MAP: 'loginToAccountIDMap', GUIDE_ACCOUNT_IDS: 'guideAccountIDs', + SPEND_DATA_SIGNATURE: 'spendDataSignature', + ONE_ON_ONE_CHAT_REPORT_IDS: 'oneOnOneChatReportIDs', }, /** Stores HybridApp specific state required to interoperate with OldDot */ @@ -1543,6 +1581,8 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE]: OnyxTypes.ReportLoadingState; [ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE]: OnyxTypes.CompanyCardsLoadingState; [ONYXKEYS.COLLECTION.RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE]: OnyxTypes.ExpensifyCardLoadingState; + [ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE]: OnyxTypes.PolicyDataLoadingState; + [ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE]: OnyxTypes.PolicyDataLoadingState; [ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE]: OnyxTypes.ReportPaginationState; [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportActions; [ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS]: OnyxTypes.ReportActionsDrafts; @@ -1609,6 +1649,7 @@ type OnyxCollectionValuesMapping = { type OnyxValuesMapping = { [ONYXKEYS.ACCOUNT]: OnyxTypes.Account; [ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string; + [ONYXKEYS.EARLY_RENEWAL_OFFER_ELIGIBILITY]: OnyxTypes.EarlyRenewalOfferEligibility; [ONYXKEYS.DEFAULT_P2P_MILEAGE_RATE]: DefaultP2PMileageRate; [ONYXKEYS.GOVERNMENT_MILEAGE_RATES]: OnyxTypes.GovernmentMileageRate[]; @@ -1622,6 +1663,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_HAS_SEEDED_MY_EXPENSES_SEARCH]: boolean; [ONYXKEYS.SEARCH_CONTEXT]: OnyxTypes.SearchContext; [ONYXKEYS.SEARCH_FOOTER_CONVERSION]: OnyxTypes.SearchFooterConversion; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- the key stays typed while the migration still reads it [ONYXKEYS.SEARCH_QUERY_BY_HASH]: Record; [ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[]; [ONYXKEYS.ACTIVE_CLIENTS]: string[]; @@ -1747,11 +1789,16 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED]: boolean; [ONYXKEYS.IS_SEARCH_FILTERS_CATEGORY_DATA_LOADED]: boolean; + [ONYXKEYS.RAM_ONLY_HAS_RULES_DATA_BEEN_FETCHED]: boolean; + [ONYXKEYS.RAM_ONLY_IS_LOADING_RULES]: boolean; [ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA]: boolean; + [ONYXKEYS.IS_SEARCH_FILTERS_VENDOR_DATA_LOADED]: boolean; + [ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_VENDOR_DATA]: boolean; [ONYXKEYS.IS_LOADING_SUBSCRIPTION_DATA]: boolean; [ONYXKEYS.IS_PENDING_UPDATE_PERSONAL_KARMA]: boolean; [ONYXKEYS.IS_TEST_TOOLS_MODAL_OPEN]: boolean; [ONYXKEYS.IS_LOADING_APP]: boolean; + [ONYXKEYS.IS_SWITCHING_TO_DELEGATOR]: boolean; [ONYXKEYS.ARE_AGENTS_LOADED]: boolean; [ONYXKEYS.AGENT_NEW_AVATAR_DRAFT]: OnyxTypes.AgentNewAvatarDraft; [ONYXKEYS.POLICY_ROOMS_METADATA]: Record; @@ -1788,6 +1835,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; [ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string; [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: boolean; + [ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS]: string[] | null; [ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION]: OnyxTypes.SearchTagFiltersPaginationState; [ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS]: OnyxTypes.SearchTagFilterItem[]; [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS]: boolean; @@ -1889,6 +1937,7 @@ type OnyxValuesMapping = { [ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY]: OnyxTypes.LastSearchParams; [ONYXKEYS.NVP_LAST_ANDROID_LOGIN]: string; [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS]: string[]; + [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH]: number; [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS]: Record; [ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES]: OnyxTypes.ExportTemplate[]; [ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION]: OnboardingAccounting; @@ -1903,7 +1952,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[]; [ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined; [ONYXKEYS.PERSONAL_POLICY_ID]: string; - [ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record>; + [ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record; [ONYXKEYS.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null; [ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined; }; @@ -1918,7 +1967,9 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue; [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: OnyxTypes.LoginToAccountIDMapDerivedValue; + [ONYXKEYS.DERIVED.SPEND_DATA_SIGNATURE]: OnyxTypes.SpendDataSignatureDerivedValue; [ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS]: OnyxTypes.GuideAccountIDsDerivedValue; + [ONYXKEYS.DERIVED.ONE_ON_ONE_CHAT_REPORT_IDS]: OnyxTypes.OneOnOneChatReportIDsDerivedValue; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 40eb53dd6ede..f10be93c951c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -125,6 +125,7 @@ const DYNAMIC_ROUTES = { SCREENS.MONEY_REQUEST.STEP_CONFIRMATION, SCREENS.TRAVEL.MY_TRIPS, SCREENS.WORKSPACE.TRAVEL, + SCREENS.WORKSPACES_ADD_DOMAIN, ], }, CONTACT_METHODS: { @@ -1089,6 +1090,14 @@ const DYNAMIC_ROUTES = { path: 'rules/require-fields', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS, SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS], }, + WORKSPACE_CATEGORY_RULES_TAX_NEW: { + path: 'tax-default', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_RULES_NEW], + }, + WORKSPACE_CATEGORY_RULES_TAX_EDIT: { + path: 'rules/tax', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS, SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS], + }, RULES_MERCHANT_NEW_FROM_EXPENSE: { path: 'merchant-rule/new', entryScreens: [SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], @@ -1118,7 +1127,7 @@ const DYNAMIC_ROUTES = { }, RULES_MERCHANT_TAX_FROM_EXPENSE: { path: 'rule-tax', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW, SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_NEW, SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_EDIT], }, RULES_MERCHANT_VENDOR_FROM_EXPENSE: { path: 'rule-vendor', @@ -1375,6 +1384,7 @@ const DYNAMIC_ROUTES = { SCREENS.EXPENSIFY_CARD.DYNAMIC_EXPENSIFY_CARD_DETAILS, SCREENS.WORKSPACE.ACCOUNTING.RILLET_CARD_ACCOUNT_CARD_LIST, SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_CARD_ACCOUNT_CARD_LIST, + SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_ACCOUNT_CARD_LIST, ], getRoute: (feed: CardFeedWithDomainID, cardID: string) => `edit/export/${encodeURIComponent(feed)}/${encodeURIComponent(cardID)}` as const, }, @@ -2096,14 +2106,18 @@ const ROUTES = { }, SEARCH_REPORT: { route: 'search/view/:reportID/:reportActionID?', - getRoute: ({reportID, reportActionID, backTo}: {reportID: string | undefined; reportActionID?: string; backTo?: string}) => { + getRoute: ({reportID, reportActionID, backTo, anchorTransactionID}: {reportID: string | undefined; reportActionID?: string; backTo?: string; anchorTransactionID?: string}) => { if (!reportID) { Log.warn('Invalid reportID is used to build the SEARCH_REPORT route'); } const baseRoute = reportActionID ? (`search/view/${reportID}/${reportActionID}` as const) : (`search/view/${reportID}` as const); + // Which expense in the prev/next carousel this screen is anchored to. The header falls back to it while + // the thread's own parent report action is still loading, so the carousel doesn't wink out of existence + // on a cold open (e.g. straight after clearing the cache). + const routeWithAnchor = anchorTransactionID ? (`${baseRoute}?anchorTransactionID=${anchorTransactionID}` as const) : baseRoute; - return getUrlWithBackToParam(baseRoute, backTo); + return getUrlWithBackToParam(routeWithAnchor, backTo); }, }, @@ -2653,6 +2667,7 @@ const ROUTES = { SETTINGS_VACATION_DELEGATE: 'settings/profile/status/vacation-delegate', SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES: 'settings/profile/status/vacation-delegate/missing-workspaces', SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', + SETTINGS_TROUBLESHOOT_SERVER: 'settings/troubleshoot/server', SETTINGS_HELP: 'settings/help', SETTINGS_SAVE_THE_WORLD: 'settings/teachersunite', @@ -3030,12 +3045,19 @@ const ROUTES = { }, WORKSPACE_OVERVIEW_CURRENCY: { route: 'workspaces/:policyID/overview/currency', - getRoute: (policyID: string, isForcedToChangeCurrency?: boolean) => { - let queryParams = ''; + getRoute: ( + policyID: string, + {isForcedToChangeCurrency, shouldStartExpensifyCardEnrollment}: {isForcedToChangeCurrency?: boolean; shouldStartExpensifyCardEnrollment?: boolean} = {}, + ) => { + const params = new URLSearchParams(); if (isForcedToChangeCurrency) { - queryParams += `?isForcedToChangeCurrency=true`; + params.set('isForcedToChangeCurrency', 'true'); + } + if (shouldStartExpensifyCardEnrollment) { + params.set('shouldStartExpensifyCardEnrollment', 'true'); } - return `workspaces/${policyID}/overview/currency${queryParams}` as const; + const query = params.toString(); + return `workspaces/${policyID}/overview/currency${query ? `?${query}` : ''}` as const; }, }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT: { @@ -3628,6 +3650,10 @@ const ROUTES = { route: 'workspaces/:policyID/members/:accountID/role', getRoute: (policyID: string, accountID: number) => `workspaces/${policyID}/members/${accountID}/role` as const, }, + WORKSPACE_MEMBER_WORK_ARRANGEMENT: { + route: 'workspaces/:policyID/members/:accountID/work-arrangement', + getRoute: (policyID: string, accountID: number) => `workspaces/${policyID}/members/${accountID}/work-arrangement` as const, + }, WORKSPACE_CUSTOM_FIELDS: { route: 'workspaces/:policyID/members/:accountID/:customFieldType', getRoute: (policyID: string, accountID: number, customFieldType: CustomFieldType) => `/workspaces/${policyID}/members/${accountID}/${customFieldType}` as const, @@ -5094,6 +5120,22 @@ const ROUTES = { route: 'workspaces/:policyID/accounting/campfire/export/company-card-account', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/campfire/export/company-card-account` as const, }, + POLICY_ACCOUNTING_CAMPFIRE_CARD_PROGRAM_ACCOUNT: { + route: 'workspaces/:policyID/accounting/campfire/export/card-program-account', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/campfire/export/card-program-account` as const, + }, + POLICY_ACCOUNTING_CAMPFIRE_CARD_PROGRAM_ACCOUNT_SELECTOR: { + route: 'workspaces/:policyID/accounting/campfire/export/card-program-account/:feed', + getRoute: (policyID: string, feed: CardFeedWithDomainID) => `workspaces/${policyID}/accounting/campfire/export/card-program-account/${encodeURIComponent(feed)}` as const, + }, + POLICY_ACCOUNTING_CAMPFIRE_CARD_ACCOUNT: { + route: 'workspaces/:policyID/accounting/campfire/export/card-account', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/campfire/export/card-account` as const, + }, + POLICY_ACCOUNTING_CAMPFIRE_CARD_ACCOUNT_CARD_LIST: { + route: 'workspaces/:policyID/accounting/campfire/export/card-account/:feed', + getRoute: (policyID: string, feed: CardFeedWithDomainID) => `workspaces/${policyID}/accounting/campfire/export/card-account/${encodeURIComponent(feed)}` as const, + }, POLICY_ACCOUNTING_CAMPFIRE_ADVANCED: { route: 'workspaces/:policyID/accounting/campfire/advanced', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/campfire/advanced` as const, @@ -5134,6 +5176,42 @@ const ROUTES = { route: 'workspaces/:policyID/accounting/business-central/import', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/import` as const, }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT: { + route: 'workspaces/:policyID/accounting/business-central/export', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/export` as const, + }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_PREFERRED_EXPORTER: { + route: 'workspaces/:policyID/accounting/business-central/export/preferred-exporter', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/export/preferred-exporter` as const, + }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_DATE: { + route: 'workspaces/:policyID/accounting/business-central/export/date', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/export/date` as const, + }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: { + route: 'workspaces/:policyID/accounting/business-central/export/reimbursable-destination', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/export/reimbursable-destination` as const, + }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_REIMBURSABLE_ACCOUNT: { + route: 'workspaces/:policyID/accounting/business-central/export/reimbursable-account', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/export/reimbursable-account` as const, + }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_NONREIMBURSABLE_EXPENSES_EXPORT_DESTINATION: { + route: 'workspaces/:policyID/accounting/business-central/export/non-reimbursable-destination', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/export/non-reimbursable-destination` as const, + }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_COMPANY_CARD_ACCOUNT: { + route: 'workspaces/:policyID/accounting/business-central/export/company-card-account', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/export/company-card-account` as const, + }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_DEFAULT_VENDOR: { + route: 'workspaces/:policyID/accounting/business-central/export/default-vendor', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/export/default-vendor` as const, + }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_PAYMENT_METHOD: { + route: 'workspaces/:policyID/accounting/business-central/export/payment-method', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/export/payment-method` as const, + }, ADD_EXISTING_EXPENSE: { route: 'search/r/:reportID/add-existing-expense/:backToReport?', getRoute: (reportID: string | undefined, backToReport?: string) => `search/r/${reportID}/add-existing-expense/${backToReport ?? ''}` as const, @@ -5233,6 +5311,11 @@ const ROUTES = { getRoute: (backTo?: string) => getUrlWithBackToParam('test-tools' as const, backTo), }, + TEST_TOOLS_SERVER: { + route: 'test-tools/server', + + getRoute: (backTo?: string) => getUrlWithBackToParam('test-tools/server' as const, backTo), + }, WORKSPACES_VERIFY_DOMAIN: { route: 'workspaces/verify-domain/:domainAccountID', getRoute: (domainAccountID: number) => `workspaces/verify-domain/${domainAccountID}` as const, @@ -5242,7 +5325,6 @@ const ROUTES = { getRoute: (domainAccountID: number) => `workspaces/domain-verified/${domainAccountID}` as const, }, WORKSPACES_ADD_DOMAIN: 'workspaces/add-domain', - WORKSPACES_ADD_DOMAIN_VERIFY_ACCOUNT: `workspaces/add-domain/${VERIFY_ACCOUNT}`, WORKSPACES_DOMAIN_ADDED: { route: 'workspaces/domain-added/:domainAccountID', getRoute: (domainAccountID: number) => `workspaces/domain-added/${domainAccountID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index f39a2448a606..1e6e1d4f9b0c 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -93,6 +93,7 @@ const SCREENS = { REPORT_CARD_LOST_OR_DAMAGED: 'Settings_ReportCardLostOrDamaged', REPORT_CARD_LOST_OR_DAMAGED_CONFIRM_VALIDATE_CODE: 'Settings_ReportCardLostOrDamaged_ConfirmValidateCode', TROUBLESHOOT: 'Settings_Troubleshoot', + TROUBLESHOOT_SERVER: 'Settings_Troubleshoot_Server', HELP: 'Settings_Help', DYNAMIC_VERIFY_ACCOUNT: 'Dynamic_Verify_Account', DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT: 'Dynamic_Add_Bank_Account_Verify_Account', @@ -740,6 +741,10 @@ const SCREENS = { CAMPFIRE_VENDOR_BILL_DATE: 'Policy_Accounting_Campfire_Vendor_Bill_Date', CAMPFIRE_DEFAULT_COMPANY_CARD_VENDOR: 'Policy_Accounting_Campfire_Default_Company_Card_Vendor', CAMPFIRE_COMPANY_CARD_ACCOUNT: 'Policy_Accounting_Campfire_Company_Card_Account', + CAMPFIRE_CARD_PROGRAM_ACCOUNT: 'Policy_Accounting_Campfire_Card_Program_Account', + CAMPFIRE_CARD_PROGRAM_ACCOUNT_SELECTOR: 'Policy_Accounting_Campfire_Card_Program_Account_Selector', + CAMPFIRE_CARD_ACCOUNT: 'Policy_Accounting_Campfire_Card_Account', + CAMPFIRE_CARD_ACCOUNT_CARD_LIST: 'Policy_Accounting_Campfire_Card_Account_Card_List', CAMPFIRE_ADVANCED: 'Policy_Accounting_Campfire_Advanced', CAMPFIRE_EXPORT_METHOD: 'Policy_Accounting_Campfire_Export_Method', CAMPFIRE_BILL_PAYMENT_ACCOUNT: 'Policy_Accounting_Campfire_Bill_Payment_Account', @@ -750,6 +755,15 @@ const SCREENS = { BUSINESS_CENTRAL_SETUP: 'Policy_Accounting_Business_Central_Setup', BUSINESS_CENTRAL_COMPANY_SELECTOR: 'Policy_Accounting_Business_Central_Company_Selector', BUSINESS_CENTRAL_IMPORT: 'Policy_Accounting_Business_Central_Import', + BUSINESS_CENTRAL_EXPORT: 'Policy_Accounting_Business_Central_Export', + BUSINESS_CENTRAL_EXPORT_PREFERRED_EXPORTER: 'Policy_Accounting_Business_Central_Export_Preferred_Exporter', + BUSINESS_CENTRAL_EXPORT_DATE: 'Policy_Accounting_Business_Central_Export_Date', + BUSINESS_CENTRAL_EXPORT_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'Policy_Accounting_Business_Central_Export_Reimbursable_Expenses_Export_Destination', + BUSINESS_CENTRAL_EXPORT_REIMBURSABLE_ACCOUNT: 'Policy_Accounting_Business_Central_Export_Reimbursable_Account', + BUSINESS_CENTRAL_EXPORT_NONREIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'Policy_Accounting_Business_Central_Export_Nonreimbursable_Expenses_Export_Destination', + BUSINESS_CENTRAL_EXPORT_COMPANY_CARD_ACCOUNT: 'Policy_Accounting_Business_Central_Export_Company_Card_Account', + BUSINESS_CENTRAL_EXPORT_DEFAULT_VENDOR: 'Policy_Accounting_Business_Central_Export_Default_Vendor', + BUSINESS_CENTRAL_EXPORT_PAYMENT_METHOD: 'Policy_Accounting_Business_Central_Export_Payment_Method', CARD_RECONCILIATION: 'Policy_Accounting_Card_Reconciliation', CARD_RECONCILIATION_SAGE_INTACCT_AUTO_SYNC: 'Policy_Accounting_Card_Reconciliation_Sage_Intacct_Auto_Sync', DYNAMIC_RECONCILIATION_ACCOUNT_SETTINGS: 'Dynamic_Policy_Accounting_Reconciliation_Account_Settings', @@ -920,6 +934,8 @@ const SCREENS = { DYNAMIC_CATEGORY_FLAG_FOR_REVIEW_RULE_AMOUNT: 'Dynamic_Category_Flag_For_Review_Rule_Amount', DYNAMIC_CATEGORY_REQUIRE_FIELDS_RULE_NEW: 'Dynamic_Category_Require_Fields_Rule_New', DYNAMIC_CATEGORY_REQUIRE_FIELDS_RULE_EDIT: 'Dynamic_Category_Require_Fields_Rule_Edit', + DYNAMIC_CATEGORY_TAX_RULE_NEW: 'Dynamic_Category_Tax_Rule_New', + DYNAMIC_CATEGORY_TAX_RULE_EDIT: 'Dynamic_Category_Tax_Rule_Edit', CATEGORIES_SETTINGS: 'Categories_Settings', DYNAMIC_CATEGORIES_SETTINGS: 'Dynamic_Categories_Settings', DYNAMIC_CATEGORIES_IMPORT: 'Dynamic_Categories_Import', @@ -929,6 +945,7 @@ const SCREENS = { MORE_FEATURES: 'Workspace_More_Features', MEMBER_DETAILS: 'Workspace_Member_Details', MEMBER_DETAILS_ROLE: 'Workspace_Member_Details_Role', + MEMBER_WORK_ARRANGEMENT: 'Workspace_Member_Work_Arrangement', MEMBER_CUSTOM_FIELD: 'Workspace_Member_Custom_Field', MEMBER_NEW_CARD: 'Workspace_Member_NewCard', DYNAMIC_OWNER_CHANGE_CHECK: 'Dynamic_Workspace_Owner_Change_Check', @@ -1167,11 +1184,11 @@ const SCREENS = { }, TEST_TOOLS_MODAL: { ROOT: 'TestToolsModal_Root', + SERVER: 'TestToolsModal_Server', }, WORKSPACES_VERIFY_DOMAIN: 'Workspaces_Verify_Domain', WORKSPACES_DOMAIN_VERIFIED: 'Workspaces_Domain_Verified', WORKSPACES_ADD_DOMAIN: 'Workspaces_Add_Domain', - WORKSPACES_ADD_DOMAIN_VERIFY_ACCOUNT: 'Workspaces_Add_Domain_VerifyAccount', WORKSPACES_DOMAIN_ADDED: 'Workspaces_Domain_Added', WORKSPACES_DOMAIN_ACCESS_RESTRICTED: 'Workspaces_Domain_Access_Restricted', WORKSPACES_DOMAIN_ALREADY_EXISTS: 'Workspaces_Domain_Already_Exists', diff --git a/src/components/AccountSwitcherButton.tsx b/src/components/AccountSwitcherButton.tsx index e7697eb41cc2..476bbf091f28 100644 --- a/src/components/AccountSwitcherButton.tsx +++ b/src/components/AccountSwitcherButton.tsx @@ -15,7 +15,7 @@ import {clearDelegatorErrors, connect, disconnect} from '@libs/actions/Delegate' import {close} from '@libs/actions/Modal'; import {getLatestError} from '@libs/ErrorUtils'; import {getGpsPoints, stopGpsTrip} from '@libs/GPSDraftDetailsUtils'; -import {sortAlphabetically} from '@libs/OptionsListUtils'; +import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; import type {AnchorPosition} from '@styles/index'; diff --git a/src/components/AddExistingExpenseFooter.tsx b/src/components/AddExistingExpenseFooter.tsx index 2e0a07da4fe1..5a3a5831fff9 100644 --- a/src/components/AddExistingExpenseFooter.tsx +++ b/src/components/AddExistingExpenseFooter.tsx @@ -63,6 +63,7 @@ function AddExistingExpenseFooter({selectedIds, report, reportToConfirm, policy, const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReportID)}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const [transactions] = useTransactionsByID(selectedIds); const reports = useChangeTransactionsReportReports(transactions, reportToConfirm?.reportID); @@ -111,6 +112,7 @@ function AddExistingExpenseFooter({selectedIds, report, reportToConfirm, policy, allTransactionViolation: transactionViolations, reports, rules, + cardList, isTrackIntentUser, personalPolicyOutputCurrency: personalPolicy?.outputCurrency, selfDMReportActions, diff --git a/src/components/ApprovalWorkflowSection.tsx b/src/components/ApprovalWorkflowSection.tsx index a5127864dfd3..942e62683cac 100644 --- a/src/components/ApprovalWorkflowSection.tsx +++ b/src/components/ApprovalWorkflowSection.tsx @@ -5,7 +5,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {sortAlphabetically} from '@libs/OptionsListUtils'; +import sortAlphabetically from '@libs/sortAlphabetically'; import {getApprovalLimitDescription} from '@libs/WorkflowUtils'; import CONST from '@src/CONST'; diff --git a/src/components/ApproverSelectionList.tsx b/src/components/ApproverSelectionList.tsx index 2775c1edf2ab..833e9a64baec 100644 --- a/src/components/ApproverSelectionList.tsx +++ b/src/components/ApproverSelectionList.tsx @@ -6,9 +6,10 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import {getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; +import {getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import {goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; +import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; import variables from '@styles/variables'; @@ -191,7 +192,7 @@ function ApproverSelectionList({ shouldUpdateFocusedIndex={shouldUpdateFocusedIndex} disableMaintainingScrollPosition showScrollIndicator - isRowMultilineSupported + titleNumberOfLines={2} /> diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx index 360b4f28371b..7b8454966b8c 100644 --- a/src/components/ArchivedReportFooter.tsx +++ b/src/components/ArchivedReportFooter.tsx @@ -1,6 +1,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; @@ -9,8 +10,6 @@ import {getPolicyName} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList} from '@src/types/onyx'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; import {getLastClosedReportAction} from '@selectors/ReportAction'; import lodashEscape from 'lodash/escape'; @@ -28,10 +27,10 @@ function ArchivedReportFooter({reportID}: ArchivedReportFooterProps) { const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [personalDetails = getEmptyObject()] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [reportClosedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: getLastClosedReportAction}); const originalMessage = isClosedAction(reportClosedAction) ? getOriginalMessage(reportClosedAction) : null; const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT; + const [personalDetails] = usePersonalDetailsByIDs([reportClosedAction?.actorAccountID, originalMessage?.newAccountID, originalMessage?.oldAccountID]); const actorPersonalDetails = personalDetails?.[reportClosedAction?.actorAccountID ?? CONST.DEFAULT_NUMBER_ID]; let displayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: actorPersonalDetails, translate, formatPhoneNumber}); diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx index 3ab9ffb5a3ad..c207987a8ad7 100644 --- a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx @@ -23,7 +23,7 @@ import CONST from '@src/CONST'; import type {RefObject} from 'react'; import type {ListRenderItemInfo} from 'react-native'; -import type {ComposedGesture, GestureType} from 'react-native-gesture-handler'; +import type {LegacyComposedGesture, GestureType} from 'react-native-gesture-handler'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {Keyboard, PixelRatio, View} from 'react-native'; @@ -42,7 +42,7 @@ const MIN_FLING_VELOCITY = 500; type DeviceAwareGestureDetectorProps = { canUseTouchScreen: boolean; - gesture: ComposedGesture | GestureType; + gesture: LegacyComposedGesture | GestureType; children: React.ReactNode; }; diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index 61132ec616cd..d573a3bba224 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -2,11 +2,11 @@ import {useAttachmentCarouselPagerActions} from '@components/Attachments/Attachm import MultiGestureIcon from '@components/Attachments/MultiGestureIcon'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import Button from '@components/Button'; -import DistanceEReceipt from '@components/DistanceEReceipt'; import EReceipt from '@components/EReceipt'; import Icon from '@components/Icon'; import {useSession} from '@components/OnyxListItemProvider'; import PerDiemEReceipt from '@components/PerDiemEReceipt'; +import ScaledDistanceEReceipt from '@components/ScaledDistanceEReceipt'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import {usePlaybackActionsContext} from '@components/VideoPlayerContexts/PlaybackContext'; @@ -28,7 +28,7 @@ import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {getFileResolution, isHighResolutionImage} from '@libs/fileDownload/FileUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {hasEReceipt, hasReceiptSource, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, isPerDiemRequest} from '@libs/TransactionUtils'; +import {hasEReceipt, hasReceiptSource, isMapBasedDistanceRequest, isPerDiemRequest} from '@libs/TransactionUtils'; import type {ColorValue} from '@styles/utils/types'; import variables from '@styles/variables'; @@ -244,6 +244,16 @@ function AttachmentView({ ); } + // New Expensify builds the distance e-receipt from the expense, which is why the server stores only the route + // map as the thumbnail for it to draw around. The generated PDF beside it is for Expensify Classic, which + // cannot build one in the frontend, and it prints the routed trip rather than what the expense bills. Showing + // that PDF here made the enlarged receipt contradict every other surface, so draw the card instead. This runs + // before the PDF branch below, which would otherwise return first. + // See https://github.com/Expensify/Expensify/issues/545298 and https://github.com/Expensify/App/issues/97013. + if (transaction && isMapBasedDistanceRequest(transaction)) { + return ; + } + // Check both source and file.name since PDFs dragged into the text field // will appear with a source that is a blob const isSourcePDF = typeof source === 'string' && Str.isPDF(source); @@ -303,14 +313,6 @@ function AttachmentView({ ); } - if (isDistanceRequest(transaction) && !isManualDistanceRequest(transaction) && !isOdometerDistanceRequest(transaction) && transaction) { - // Distance eReceipts are now generated as a PDF, but to keep it backwards compatible we still show the old eReceipt view for image receipts - const isImageReceiptSource = checkIsFileImage(source, file?.name, file?.type); - if (!hasReceiptSource(transaction) || isImageReceiptSource) { - return ; - } - } - // For this check we use both source and file.name since temporary file source is a blob // both PDFs and images will appear as images when pasted into the text field. // We also check for numeric source since this is how static images (used for preview) are represented in RN. diff --git a/src/components/Avatar/connected/ChatThreadAvatar.tsx b/src/components/Avatar/connected/ChatThreadAvatar.tsx new file mode 100644 index 000000000000..8782125c547d --- /dev/null +++ b/src/components/Avatar/connected/ChatThreadAvatar.tsx @@ -0,0 +1,166 @@ +import type {HorizontalStackingOptions} from '@components/Avatar/layouts/HorizontalAvatars'; +import SingleAvatar from '@components/Avatar/layouts/SingleAvatar'; +import SubscriptAvatar from '@components/Avatar/layouts/SubscriptAvatar'; +import type {AvatarIcon} from '@components/Avatar/types'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; + +import useDefaultAvatars from '@hooks/useDefaultAvatars'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useStyleUtils from '@hooks/useStyleUtils'; + +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import { + getDelegateAccountIDFromReportAction, + getHumanAgentAccountIDFromReportAction, + getHumanAgentFirstName, + getOriginalMessage, + getReportActionActorAccountID, + isMoneyRequestAction, + isTransactionThread, +} from '@libs/ReportActionsUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {ColorValue, StyleProp, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; + +import {reportAvatarFieldsSelector, reportAvatarKindSelector} from '@selectors/Report'; +import {getParentReportActionSelector} from '@selectors/ReportAction'; +import React from 'react'; + +import useAccountIcons, {seedFallbackIcons} from './useAccountIcons'; +import WorkspaceHorizontalAvatars from './WorkspaceHorizontalAvatars'; +import WorkspaceSubscriptAvatar from './WorkspaceSubscriptAvatar'; + +type SortingOption = ValueOf; + +type ChatThreadAvatarProps = { + /** Chat thread whose avatars to render */ + reportID: string; + + /** Size of the avatar */ + size: ValueOf; + + /** Color of the row surface behind the avatar. Affects secondary avatar so it blends into the row. */ + backdropColor?: ColorValue; + + /** Container styles for the single-avatar layout. Replaces the size-derived default container styles when provided */ + containerStyle?: StyleProp; + + /** Container styles for the subscript stack, merged over its size-derived defaults */ + subscriptContainerStyle?: StyleProp; + + /** Whether (and how) to stack the avatars horizontally. Only a thread that pairs its actor with a workspace icon has a second avatar to stack */ + horizontalStacking?: HorizontalStackingOptions | boolean; + + /** How to order the avatars before rendering them. Only applies to a horizontal stack, where every avatar sits in an equivalent slot */ + sort?: SortingOption | SortingOption[]; + + /** Display name used as a fallback for avatar tooltips */ + fallbackDisplayName?: string; +}; + +/** Renders a chat thread's avatars from its parent report action. */ +function ChatThreadAvatar({reportID, size, backdropColor, containerStyle, subscriptContainerStyle, horizontalStacking, sort, fallbackDisplayName}: ChatThreadAvatarProps) { + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const personalDetails = usePersonalDetails(); + const defaultAvatars = useDefaultAvatars(); + + const [thread] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: reportAvatarFieldsSelector}); + const parentReportID = getNonEmptyStringOnyxID(thread?.parentReportID); + const [parentKind] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, {selector: reportAvatarKindSelector}); + const [parentAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + selector: (actions) => getParentReportActionSelector(actions, thread?.parentReportActionID), + }); + + // A transaction thread points `chatReportID` at its parent, a comment thread leaves it unset. Only a linked thread shows the copilot, the revealed agent and the expense subscript. + const isParentActionLinked = !!parentKind && !!thread?.chatReportID && thread.chatReportID === thread.parentReportID; + // An expense request is a transaction thread under an expense report. + const isExpenseRequest = parentKind === CONST.REPORT_AVATAR_KIND.EXPENSE && isTransactionThread(parentAction); + // Only a created expense takes the subscript. A tracked expense or a paid send-money action keeps a single avatar. + const isCreatedExpenseRequest = + isParentActionLinked && isExpenseRequest && isMoneyRequestAction(parentAction) && getOriginalMessage(parentAction)?.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE; + // A trip room is a thread of its trip preview, so it gets the workspace subscript too. + const hasTripRoomChatType = thread?.chatType === CONST.REPORT.CHAT_TYPE.TRIP_ROOM; + const isArchivedTripRoom = useReportIsArchived(hasTripRoomChatType ? reportID : undefined); + // Once a linked parent action loads, only a trip preview gives the subscript, archived or not. Without one, an archived trip room drops it. A horizontal stack never takes it. + const hasTripRoomSubscript = + !horizontalStacking && hasTripRoomChatType && (isParentActionLinked && !!parentAction ? parentAction.actionName === CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW : !isArchivedTripRoom); + // A thread inherits its room's chat type. + const isWorkspaceThread = CONST.WORKSPACE_ROOM_TYPES.some((chatType) => thread?.chatType === chatType); + + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + // A thread under the Concierge chat is a conversation with Concierge, so it shows Concierge rather than whoever asked. + const isConciergeThread = !!conciergeReportID && parentReportID === conciergeReportID; + + const delegateAccountID = isParentActionLinked && !isConciergeThread ? getDelegateAccountIDFromReportAction(parentAction) : undefined; + // Concierge for harvested and automatic actions. + const actorAccountID = getReportActionActorAccountID(parentAction, undefined, undefined); + const humanAgentAccountID = isParentActionLinked ? getHumanAgentAccountIDFromReportAction(parentAction) : undefined; + // The copilot and a revealed agent only show once their personal details have loaded. Until then the actor or Concierge stands alone, though the copilot badge still shows. + const loadedDelegateAccountID = delegateAccountID && personalDetails?.[delegateAccountID] ? delegateAccountID : undefined; + const loadedHumanAgentAccountID = humanAgentAccountID && personalDetails?.[humanAgentAccountID] ? humanAgentAccountID : undefined; + const primaryAccountID = isConciergeThread ? CONST.ACCOUNT_ID.CONCIERGE : (loadedDelegateAccountID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID); + const iconAccountIDs = loadedHumanAgentAccountID ? [primaryAccountID, loadedHumanAgentAccountID] : [primaryAccountID]; + const accountIcons = useAccountIcons(iconAccountIDs); + // A linked thread keeps the generic fallback for an account without personal details. Any other thread seeds a default avatar from the account ID. + const [primaryAvatar, humanAgentIcon] = isParentActionLinked ? accountIcons : seedFallbackIcons(accountIcons, iconAccountIDs, defaultAvatars.FallbackAvatar); + + // A horizontal stack pairs every workspace thread with its workspace icon. Without one, only a created expense request and a trip room show it, as a subscript. + if (horizontalStacking && (isExpenseRequest || (hasTripRoomChatType && !isArchivedTripRoom) || isWorkspaceThread)) { + return ( + + ); + } + + // The subscript shows the copilot as the primary avatar without the copilot badge, which only the single avatar carries. + if (isCreatedExpenseRequest || hasTripRoomSubscript) { + return ( + + ); + } + + if (humanAgentIcon) { + return ( + + ); + } + + const singleAvatar: AvatarIcon = delegateAccountID ? {...primaryAvatar, copilot: {accountID: delegateAccountID, actedForAccountID: actorAccountID}} : primaryAvatar; + + return ( + + ); +} + +export default ChatThreadAvatar; diff --git a/src/components/Avatar/connected/ExpenseReportAvatar.tsx b/src/components/Avatar/connected/ExpenseReportAvatar.tsx index 681b38b08fbf..c28e48b1fc2e 100644 --- a/src/components/Avatar/connected/ExpenseReportAvatar.tsx +++ b/src/components/Avatar/connected/ExpenseReportAvatar.tsx @@ -1,12 +1,9 @@ -import SubscriptAvatar from '@components/Avatar/layouts/SubscriptAvatar'; import type {AvatarIcon} from '@components/Avatar/types'; -import useDefaultAvatars from '@hooks/useDefaultAvatars'; import useOnyx from '@hooks/useOnyx'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getDelegateAccountIDFromReportAction} from '@libs/ReportActionsUtils'; -import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -16,12 +13,12 @@ import type {ColorValue, StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import {expenseReportAvatarSelector} from '@selectors/Report'; +import {reportAvatarFieldsSelector} from '@selectors/Report'; import {getReportActionByIDSelector} from '@selectors/ReportAction'; import React from 'react'; -import useAccountIcons from './useAccountIcons'; -import useReportWorkspaceIcon from './useReportWorkspaceIcon'; +import {useSeededAccountIcons} from './useAccountIcons'; +import WorkspaceSubscriptAvatar from './WorkspaceSubscriptAvatar'; type ExpenseReportAvatarProps = { /** Expense report whose avatars to render */ @@ -41,32 +38,25 @@ type ExpenseReportAvatarProps = { }; /** - * Renders an expense report's avatars: the report owner as the primary avatar with the workspace icon as the subscript. + * Renders an expense report's avatars: the owner with the workspace icon as the subscript. * When a copilot created the report on the owner's behalf, the copilot is the primary avatar instead, matching the LHN row. - * Expense reports never render in any other layout. */ function ExpenseReportAvatar({reportID, size, backdropColor, containerStyle, fallbackDisplayName}: ExpenseReportAvatarProps) { - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: expenseReportAvatarSelector}); - const defaultAvatars = useDefaultAvatars(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: reportAvatarFieldsSelector}); const parentReportActionID = report?.parentReportActionID; const delegateAccountIDSelector = (reportActions: OnyxEntry) => getDelegateAccountIDFromReportAction(getReportActionByIDSelector(reportActions, parentReportActionID)); + // An optimistic expense report links its workspace chat only through chatReportID. const parentChatReportID = getNonEmptyStringOnyxID(report?.chatReportID) ?? getNonEmptyStringOnyxID(report?.parentReportID); const [delegateAccountID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentChatReportID}`, {selector: delegateAccountIDSelector}); const ownerAccountID = report?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID; const primaryAccountID = delegateAccountID ?? ownerAccountID; - const [primaryIcon] = useAccountIcons([primaryAccountID]); - // Get deterministic user fallback icon instead of generic. - const resolvedIcon = - primaryIcon.source === defaultAvatars.FallbackAvatar && primaryAccountID !== CONST.DEFAULT_NUMBER_ID - ? {...primaryIcon, source: getDefaultAvatarURL({accountID: primaryAccountID})} - : primaryIcon; - const primaryAvatar: AvatarIcon = delegateAccountID ? {...resolvedIcon, copilot: {accountID: delegateAccountID, actedForAccountID: ownerAccountID}} : resolvedIcon; - const workspaceIcon = useReportWorkspaceIcon(report); + const [primaryIcon] = useSeededAccountIcons([primaryAccountID]); + const primaryAvatar: AvatarIcon = delegateAccountID ? {...primaryIcon, copilot: {accountID: delegateAccountID, actedForAccountID: ownerAccountID}} : primaryIcon; return ( - ; + +type PolicyExpenseChatAvatarProps = { + /** Policy expense chat whose avatars to render */ + reportID: string; + + /** Size of the avatar */ + size: ValueOf; + + /** Color of the row surface behind the avatar. Affects secondary avatar so it blends into the row. */ + backdropColor?: ColorValue; + + /** Container styles for the single-avatar layout. Replaces the size-derived default container styles when provided */ + containerStyle?: StyleProp; + + /** Container styles for the subscript stack, merged over its size-derived defaults */ + subscriptContainerStyle?: StyleProp; + + /** Whether (and how) to stack the workspace icon and the member side by side instead of nesting the member as the subscript */ + horizontalStacking?: HorizontalStackingOptions | boolean; + + /** How to order the avatars before rendering them. Only applies to a horizontal stack, where every avatar sits in an equivalent slot */ + sort?: SortingOption | SortingOption[]; + + /** Display name used as a fallback for avatar tooltips */ + fallbackDisplayName?: string; +}; + +/** Renders a policy expense chat's avatars: the workspace icon with the member as the subscript, or side by side inside a horizontal stack. */ +function PolicyExpenseChatAvatar({reportID, size, backdropColor, containerStyle, subscriptContainerStyle, horizontalStacking, sort, fallbackDisplayName}: PolicyExpenseChatAvatarProps) { + const StyleUtils = useStyleUtils(); + const {formatPhoneNumber, translate} = useLocalize(); + const [personalDetailsFromSnapshot] = useAllPersonalDetails(); + // On Search, the snapshot can hold a member missing from the live list. Like the legacy component, fall back to the live list while the snapshot loads. + const personalDetails = personalDetailsFromSnapshot ?? use(PersonalDetailsContext); + const defaultAvatars = useDefaultAvatars(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: reportAvatarFieldsSelector}); + const workspaceIcon = useReportWorkspaceIcon(report); + + // The unknown account, standing in for a missing workspace or member the same way the legacy component does. + const placeholderIcon: Icon = {id: CONST.DEFAULT_NUMBER_ID, type: CONST.ICON_TYPE_AVATAR, source: defaultAvatars.FallbackAvatar, name: ''}; + // A chat without a policyID has no workspace to show, so the legacy component falls back to the unknown account instead. + const primaryIcon = report?.policyID ? workspaceIcon : placeholderIcon; + const ownerAccountID = report?.ownerAccountID; + const ownerDetails = ownerAccountID ? personalDetails?.[ownerAccountID] : undefined; + // Built like the legacy member icon: a default avatar seeded from the account ID, named by display name, then login, then "Hidden" while personal details are missing. + const ownerIcon: Icon = ownerAccountID + ? { + id: ownerAccountID, + type: CONST.ICON_TYPE_AVATAR, + source: ownerDetails?.avatar ?? getDefaultAvatarURL({accountID: ownerAccountID}), + name: temporaryGetDisplayNameOrDefault({passedPersonalDetails: ownerDetails, translate, formatPhoneNumber}), + fallbackIcon: ownerDetails?.fallbackIcon, + } + : placeholderIcon; + // The workspace leads the row, unlike a thread or an expense report where the account does. + const icons = useSortedIcons([primaryIcon, ownerIcon], sort); + + // A stack always shows both avatars, even without a member. + if (horizontalStacking) { + return ( + + ); + } + + // A member without personal details keeps the subscript. Only a chat without a member drops it. + if (ownerAccountID) { + return ( + + ); + } + + return ( + + ); +} + +export default PolicyExpenseChatAvatar; diff --git a/src/components/Avatar/connected/ReportAvatar.tsx b/src/components/Avatar/connected/ReportAvatar.tsx index dd9c4ef134bd..eecea10fe724 100644 --- a/src/components/Avatar/connected/ReportAvatar.tsx +++ b/src/components/Avatar/connected/ReportAvatar.tsx @@ -15,8 +15,10 @@ import {reportAvatarKindSelector} from '@selectors/Report'; import React from 'react'; import AccountAvatar from './AccountAvatar'; +import ChatThreadAvatar from './ChatThreadAvatar'; import ExpenseReportAvatar from './ExpenseReportAvatar'; import GroupChatAvatar from './GroupChatAvatar'; +import PolicyExpenseChatAvatar from './PolicyExpenseChatAvatar'; type SortingOption = ValueOf; @@ -50,8 +52,8 @@ function ReportAvatar({ backdropColor, subscriptAvatarContainerStyle, horizontalStacking, + sort, fallbackDisplayName, - ...rest }: ReportAvatarProps) { const [kindFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`, {selector: reportAvatarKindSelector}); const kind = kindFromOnyx ?? CONST.REPORT_AVATAR_KIND.DEFAULT; @@ -88,13 +90,39 @@ function ReportAvatar({ fallbackDisplayName={fallbackDisplayName} /> ); + case CONST.REPORT_AVATAR_KIND.CHAT_THREAD: + return ( + + ); + case CONST.REPORT_AVATAR_KIND.POLICY_EXPENSE_CHAT: + return ( + + ); // TODO: The remaining kinds still render the legacy component. https://github.com/Expensify/App/issues/94590 adds a // dedicated wrapper per kind, one PR at a time. The last of those deletes the ReportActionAvatars import and simplifies props. case CONST.REPORT_AVATAR_KIND.IOU: case CONST.REPORT_AVATAR_KIND.TASK: case CONST.REPORT_AVATAR_KIND.INVOICE: - case CONST.REPORT_AVATAR_KIND.CHAT_THREAD: - case CONST.REPORT_AVATAR_KIND.POLICY_EXPENSE_CHAT: case CONST.REPORT_AVATAR_KIND.ROOM: case CONST.REPORT_AVATAR_KIND.DEFAULT: default: @@ -106,8 +134,8 @@ function ReportAvatar({ backdropColor={backdropColor} subscriptAvatarContainerStyle={subscriptAvatarContainerStyle} horizontalStacking={horizontalStacking} + sort={sort} fallbackDisplayName={fallbackDisplayName} - {...rest} /> ); } diff --git a/src/components/Avatar/connected/WorkspaceHorizontalAvatars.tsx b/src/components/Avatar/connected/WorkspaceHorizontalAvatars.tsx new file mode 100644 index 000000000000..04c99e96be9f --- /dev/null +++ b/src/components/Avatar/connected/WorkspaceHorizontalAvatars.tsx @@ -0,0 +1,55 @@ +import HorizontalAvatars from '@components/Avatar/layouts/HorizontalAvatars'; +import type {HorizontalStackingOptions} from '@components/Avatar/layouts/HorizontalAvatars'; + +import type CONST from '@src/CONST'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; + +import type {ReportAvatarFields} from '@selectors/Report'; +import type {ValueOf} from 'type-fest'; + +import React from 'react'; + +import useReportWorkspaceIcon from './useReportWorkspaceIcon'; +import useSortedIcons from './useSortedIcons'; + +type SortingOption = ValueOf; + +type WorkspaceHorizontalAvatarsProps = { + /** The report the workspace icon resolves from, through its policy and the chat it links to */ + report: ReportAvatarFields | undefined; + + /** The account the report is about */ + primaryAvatar: Icon; + + /** Size of the avatars */ + size: ValueOf; + + /** How to stack the avatars. `true` takes the stack's defaults */ + horizontalStacking: HorizontalStackingOptions | true; + + /** How to order the avatars before rendering them */ + sort?: SortingOption | SortingOption[]; + + /** Display name used as a fallback for avatar tooltips */ + fallbackDisplayName?: string; +}; + +/** Renders the given account and the report's workspace icon side by side, in the requested order. */ +function WorkspaceHorizontalAvatars({report, primaryAvatar, size, horizontalStacking, sort, fallbackDisplayName}: WorkspaceHorizontalAvatarsProps) { + const workspaceIcon = useReportWorkspaceIcon(report); + const {isHovered = false, ...stackingOptions} = horizontalStacking === true ? {} : horizontalStacking; + const icons = useSortedIcons([primaryAvatar, workspaceIcon], sort); + + return ( + + ); +} + +export default WorkspaceHorizontalAvatars; diff --git a/src/components/Avatar/connected/WorkspaceSubscriptAvatar.tsx b/src/components/Avatar/connected/WorkspaceSubscriptAvatar.tsx new file mode 100644 index 000000000000..54bb7badbfeb --- /dev/null +++ b/src/components/Avatar/connected/WorkspaceSubscriptAvatar.tsx @@ -0,0 +1,50 @@ +import SubscriptAvatar from '@components/Avatar/layouts/SubscriptAvatar'; +import type {AvatarIcon} from '@components/Avatar/types'; + +import type CONST from '@src/CONST'; + +import type {ReportAvatarFields} from '@selectors/Report'; +import type {ColorValue, StyleProp, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; + +import React from 'react'; + +import useReportWorkspaceIcon from './useReportWorkspaceIcon'; + +type WorkspaceSubscriptAvatarProps = { + /** The report the workspace icon resolves from, through its policy and the chat it links to */ + report: ReportAvatarFields | undefined; + + /** The account the report is about, or the copilot who acted for it */ + primaryAvatar: AvatarIcon; + + /** Size of the avatar */ + size: ValueOf; + + /** Color of the row surface behind the avatar. Affects secondary avatar so it blends into the row. */ + backdropColor?: ColorValue; + + /** Container styles for the subscript stack, merged over its size-derived defaults */ + containerStyle?: StyleProp; + + /** Display name used as a fallback for avatar tooltips */ + fallbackDisplayName?: string; +}; + +/** Renders the given account as the primary avatar with the report's workspace icon as the subscript. */ +function WorkspaceSubscriptAvatar({report, primaryAvatar, size, backdropColor, containerStyle, fallbackDisplayName}: WorkspaceSubscriptAvatarProps) { + const workspaceIcon = useReportWorkspaceIcon(report); + + return ( + + ); +} + +export default WorkspaceSubscriptAvatar; diff --git a/src/components/Avatar/connected/useAccountIcons.ts b/src/components/Avatar/connected/useAccountIcons.ts index d02de19b6b64..8132ae763e72 100644 --- a/src/components/Avatar/connected/useAccountIcons.ts +++ b/src/components/Avatar/connected/useAccountIcons.ts @@ -2,11 +2,32 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useDefaultAvatars from '@hooks/useDefaultAvatars'; -import {buildUserIcon} from '@libs/UserAvatarUtils'; +import {buildUserIcon, getDefaultAvatarURL} from '@libs/UserAvatarUtils'; +import CONST from '@src/CONST'; import type {InvitedEmailsToAccountIDs} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; +type DefaultAvatars = ReturnType; + +function buildAccountIcons( + accountIDs: number[], + personalDetails: ReturnType, + defaultAvatars: DefaultAvatars, + invitedEmailsToAccountIDs?: InvitedEmailsToAccountIDs, + accountEmails?: Array, +): Icon[] { + return accountIDs.map((accountID, index) => + buildUserIcon({ + accountID, + personalDetails, + defaultAvatars, + invitedEmail: invitedEmailsToAccountIDs ? Object.keys(invitedEmailsToAccountIDs).find((email) => invitedEmailsToAccountIDs[email] === accountID) : undefined, + accountEmail: accountEmails?.[index], + }), + ); +} + /** * Resolves account IDs into avatar {@link Icon}s from the personal-details context and the default-avatar set. * @@ -19,15 +40,27 @@ function useAccountIcons(accountIDs: number[], invitedEmailsToAccountIDs?: Invit const personalDetails = usePersonalDetails(); const defaultAvatars = useDefaultAvatars(); - return accountIDs.map((accountID, index) => - buildUserIcon({ - accountID, - personalDetails, - defaultAvatars, - invitedEmail: invitedEmailsToAccountIDs ? Object.keys(invitedEmailsToAccountIDs).find((email) => invitedEmailsToAccountIDs[email] === accountID) : undefined, - accountEmail: accountEmails?.[index], - }), - ); + return buildAccountIcons(accountIDs, personalDetails, defaultAvatars, invitedEmailsToAccountIDs, accountEmails); +} + +/** + * Like {@link useAccountIcons}, but an account whose personal details haven't loaded gets a default avatar seeded from its + * account ID instead of the generic gray fallback. The unknown account (`DEFAULT_NUMBER_ID`) keeps the generic fallback. + */ +function useSeededAccountIcons(accountIDs: number[]): Icon[] { + const personalDetails = usePersonalDetails(); + const defaultAvatars = useDefaultAvatars(); + + return seedFallbackIcons(buildAccountIcons(accountIDs, personalDetails, defaultAvatars), accountIDs, defaultAvatars.FallbackAvatar); +} + +/** Replaces the generic fallback of each icon, index-aligned with `accountIDs`, with a default avatar seeded from its account ID. The unknown account keeps the generic fallback. */ +function seedFallbackIcons(icons: Icon[], accountIDs: number[], fallbackAvatar: DefaultAvatars['FallbackAvatar']): Icon[] { + return icons.map((icon, index) => { + const accountID = accountIDs.at(index) ?? CONST.DEFAULT_NUMBER_ID; + return icon.source === fallbackAvatar && accountID !== CONST.DEFAULT_NUMBER_ID ? {...icon, source: getDefaultAvatarURL({accountID})} : icon; + }); } export default useAccountIcons; +export {seedFallbackIcons, useSeededAccountIcons}; diff --git a/src/components/Avatar/connected/useSortedIcons.ts b/src/components/Avatar/connected/useSortedIcons.ts new file mode 100644 index 000000000000..0bfadcb83a63 --- /dev/null +++ b/src/components/Avatar/connected/useSortedIcons.ts @@ -0,0 +1,37 @@ +import {PersonalDetailsContext} from '@components/OnyxListItemProvider'; + +import useLocalize from '@hooks/useLocalize'; + +import {sortIconsByName} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; + +import type {ValueOf} from 'type-fest'; + +import lodashSortBy from 'lodash/sortBy'; +import {use} from 'react'; + +type SortingOption = ValueOf; + +/** Orders avatars for a horizontal stack, where every avatar sits in an equivalent slot. `undefined` keeps the given order. */ +function useSortedIcons(icons: Icon[], sort?: SortingOption | SortingOption[]): Icon[] { + const {localeCompare} = useLocalize(); + const sortBy: SortingOption[] = sort === undefined ? [] : [sort].flat(); + + let sortedIcons = icons; + if (sortBy.includes(CONST.REPORT_ACTION_AVATARS.SORT_BY.NAME)) { + // Read personal details only for a name sort, so other stacks don't re-render on every personal details change. + // `sortIconsByName` sorts in place, so sort a copy and leave the given array as it was + sortedIcons = sortIconsByName([...icons], use(PersonalDetailsContext), localeCompare); + } else if (sortBy.includes(CONST.REPORT_ACTION_AVATARS.SORT_BY.ID)) { + sortedIcons = lodashSortBy(icons, (icon) => icon.id); + } + if (sortBy.includes(CONST.REPORT_ACTION_AVATARS.SORT_BY.REVERSE)) { + sortedIcons = [...sortedIcons].reverse(); + } + + return sortedIcons; +} + +export default useSortedIcons; diff --git a/src/components/AvatarCropModal/ImageCropView.tsx b/src/components/AvatarCropModal/ImageCropView.tsx index adb26cfd55d4..cf7127f89563 100644 --- a/src/components/AvatarCropModal/ImageCropView.tsx +++ b/src/components/AvatarCropModal/ImageCropView.tsx @@ -9,7 +9,7 @@ import ControlSelection from '@libs/ControlSelection'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ComponentRef} from 'react'; -import type {PanGesture} from 'react-native-gesture-handler'; +import type {LegacyPanGesture} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; import React from 'react'; @@ -40,7 +40,7 @@ type ImageCropViewProps = { scale: SharedValue; /** Configuration object for pan gesture for handling image panning */ - panGesture?: PanGesture; + panGesture?: LegacyPanGesture; /** Image crop vector mask */ maskImage?: IconAsset; diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 4d9e8444f917..60a4e0dfbf60 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -384,3 +384,4 @@ function AvatarWithDisplayName({ } export default AvatarWithDisplayName; +export type {AvatarWithDisplayNameProps}; diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 2a79ec92e538..817bfb01e14a 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -14,7 +14,6 @@ import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import React, {memo} from 'react'; import {View} from 'react-native'; -import Button from './Button'; import Hoverable from './Hoverable'; import Icon from './Icon'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; @@ -52,12 +51,7 @@ type BannerProps = { /** Styles to be assigned to the Banner text */ textStyles?: StyleProp; - /** Whether to display button in the banner */ - shouldShowButton?: boolean; - - onButtonPress?: () => void; - - /** Custom action content rendered in the right side of the banner. Overrides the configured `shouldShowButton` when provided. */ + /** Custom action content rendered in the right side of the banner. */ children?: React.ReactNode; }; @@ -67,14 +61,12 @@ function Banner({ icon, onClose, onPress, - onButtonPress, containerStyles, textStyles, children, shouldRenderHTML = false, shouldShowIcon = false, shouldShowCloseButton = false, - shouldShowButton = false, }: BannerProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -125,16 +117,7 @@ function Banner({ ))} - {children ?? - (shouldShowButton && ( - - ))} + {children} {shouldShowCloseButton && !!onClose && ( ({option, onSubItemSelected}: BulkActionBarButtonProps) { const theme = useTheme(); + const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'UpArrow']); const {calculatePopoverPosition} = usePopoverPosition(); @@ -91,7 +93,12 @@ function BulkActionBarButton({option, onSubItemSelected}: BulkAction anchorRef={anchorRef} anchorPosition={anchorPosition} anchorAlignment={SUB_MENU_ANCHOR_ALIGNMENT} - menuItems={subMenuItems.map((subItem) => ({...subItem, shouldCallAfterModalHide: true}))} + headerText={option.subMenuHeaderText} + headerStyles={styles.lineHeightNormal} + menuItems={subMenuItems.map((subItem) => ({ + ...subItem, + shouldCallAfterModalHide: true, + }))} onClose={() => setIsMenuVisible(false)} onItemSelected={(selectedSubItem, index, event) => { onSubItemSelected?.(selectedSubItem, index, event); diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 4e42844312aa..a63631deb9dd 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -14,13 +14,14 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePopoverPosition from '@hooks/usePopoverPosition'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSidePanelState from '@hooks/useSidePanelState'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Accessibility from '@libs/Accessibility'; import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; -import {setDisableDismissOnEscape} from '@userActions/Modal'; +import {areAllModalsHidden, setDisableDismissOnEscape} from '@userActions/Modal'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -87,25 +88,47 @@ function BulkActionBarContent({ const moreOptions = hasMoreMenu ? options.slice(inlineActionCount) : []; // Esc clears the selection, but not while something is open over the bar. An RHP the bar is rendered inside - // reports itself visible too, and is told apart by the screen still being focused. + // reports itself visible too, and is told apart by the screen still being focused. The type is not worth reading + // here, because a popover opened and closed over an RHP leaves it stale at `popover`. const [modal] = useOnyx(ONYXKEYS.MODAL); const isFocused = useIsFocused(); - const isCoveredByModal = !!modal?.isVisible && !(isFocused && modal?.type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED); - const shouldClearSelectionOnEscape = !modal?.willAlertModalBecomeVisible && !isCoveredByModal; + const isCoveredByModal = !!modal?.isVisible && !isFocused; + + const canOwnEscape = !modal?.willAlertModalBecomeVisible && !isCoveredByModal; + + // The Side Panel is not a `BaseModal`, so it is absent from both the modal state and the registry of open modals. + // Below extra large it is laid over the page and takes Esc for itself, so the bar has to stand down for it. + const {isSidePanelHiddenOrLargeScreen} = useSidePanelState(); + const shouldClearSelectionOnEscape = canOwnEscape && isSidePanelHiddenOrLargeScreen; + + // `willAlertModalBecomeVisible` is a single flag that every modal writes, so closing the topmost of a stack reads + // as though nothing is open any more. The registry of open modals is the only answer that survives stacking, and + // it has to be read when the key arrives rather than at subscribe time. + useKeyboardShortcut( + CONST.KEYBOARD_SHORTCUTS.ESCAPE, + () => { + if (!areAllModalsHidden()) { + return; + } - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, onClearSelection, {isActive: shouldClearSelectionOnEscape}); + onClearSelection(); + }, + {isActive: shouldClearSelectionOnEscape}, + ); - // Whichever Esc handler subscribed last runs first, so holding the pane back keeps it from closing out from under - // a selection Esc was meant to clear. + // Escape must not dismiss the screen the selection sits on while the bar is the one answering it. The Side Panel + // is deliberately left out of the condition, because each write to this flag resubscribes the app's own Escape + // handler to the top of the stack, over the Side Panel's, and that one Escape would then both dismiss the screen + // and close the panel. useEffect(() => { - if (!shouldClearSelectionOnEscape) { + if (!canOwnEscape) { return; } setDisableDismissOnEscape(true); return () => setDisableDismissOnEscape(false); - }, [shouldClearSelectionOnEscape]); + }, [canOwnEscape]); useEffect(() => { if (!moreAnchorRef.current || !isMoreMenuVisible) { @@ -167,6 +190,7 @@ function BulkActionBarContent({ anchorRef={moreAnchorRef} anchorPosition={moreMenuAnchorPosition} anchorAlignment={MORE_MENU_ANCHOR_ALIGNMENT} + headerStyles={styles.lineHeightNormal} onClose={() => setIsMoreMenuVisible(false)} onItemSelected={(selectedItem, index, event) => { onSubItemSelected?.(selectedItem, index, event); diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index f5a194d026ad..40b5602a06ef 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -64,6 +64,9 @@ type DropdownOption = WithSentryLabel & { /** Whether to ignore compact popover menu styling for this item */ shouldIgnoreCompactStyle?: boolean; + + /** Text shown above `subMenuItems` once the sub-menu is open */ + subMenuHeaderText?: string; }; type ButtonWithDropdownMenuProps = WithSentryLabel & { diff --git a/src/components/CategoryPicker/CategoryPickerModal.tsx b/src/components/CategoryPicker/CategoryPickerModal.tsx index d87d61c6b721..3fee5ad3886e 100644 --- a/src/components/CategoryPicker/CategoryPickerModal.tsx +++ b/src/components/CategoryPicker/CategoryPickerModal.tsx @@ -3,24 +3,25 @@ import type PopoverWithMeasuredContentProps from '@components/PopoverWithMeasure import type {ListItem} from '@components/SelectionList/types'; import useKeyboardState from '@hooks/useKeyboardState'; +import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getEnabledCategoriesCount} from '@libs/CategoryUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import getSelectionListPopoverContentHeight from '@libs/getSelectionListPopoverContentHeight'; + import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {ComponentRef} from 'react'; -import React, {useRef} from 'react'; +import React, {useRef, useState} from 'react'; import {View} from 'react-native'; import CategoryPicker from '.'; -const popoverDimensions = { - width: CONST.POPOVER_DROPDOWN_WIDTH, - height: CONST.POPOVER_DROPDOWN_MAX_HEIGHT, -}; - const DEFAULT_ANCHOR_ALIGNMENT = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, @@ -36,6 +37,18 @@ type CategoryPickerModalProps = { /** Called when the user confirms a category selection */ onSelected?: (item: ListItem) => void; + + /** Width of the pop-over. Defaults to the standard dropdown width, a field row passes its own width instead */ + popoverWidth?: number; + + /** Height of the pop-over. Defaults to the standard dropdown height, a caller short on room passes a smaller one */ + popoverHeight?: number; + + /** Whether the pop-over shrinks to the height its list needs, treating `popoverHeight` as a ceiling. */ + shouldFitContentHeight?: boolean; + + /** Whether the pop-over may flip to the other side of the anchor when it overflows. It shifts by a whole pop-over height, so a caller that already picked the side turns this off. */ + shouldSwitchPositionIfOverflow?: boolean; } & Omit; function CategoryPickerModal({ @@ -47,6 +60,10 @@ function CategoryPickerModal({ onSelected, anchorAlignment = DEFAULT_ANCHOR_ALIGNMENT, shouldMeasureAnchorPositionFromTop = false, + popoverWidth = CONST.POPOVER_DROPDOWN_WIDTH, + popoverHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT, + shouldFitContentHeight = false, + shouldSwitchPositionIfOverflow = true, }: CategoryPickerModalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -55,6 +72,17 @@ function CategoryPickerModal({ const {isKeyboardActive} = useKeyboardState(); const anchorRef = useRef>(null); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`, {selector: getEnabledCategoriesCount}); + const [renderedRowCount, setRenderedRowCount] = useState(); + + const categoriesCount = policyCategories ?? 0; + const isSearchable = categoriesCount >= CONST.STANDARD_LIST_ITEM_LIMIT; + const estimatedContentHeight = getSelectionListPopoverContentHeight({optionCount: Math.max(renderedRowCount ?? categoriesCount, 1), isSearchable}); + + // A bottom sheet is sized by the screen, so the content estimate only applies to the pop-over. + const resolvedHeight = shouldFitContentHeight && !isSmallScreenWidth ? Math.min(popoverHeight, estimatedContentHeight) : popoverHeight; + const popoverDimensions = {width: popoverWidth, height: resolvedHeight}; + const handleCategorySelect = (item: ListItem) => { // If clicking the same category that's already selected, treat it as deselection if (item.keyForList === selectedCategory) { @@ -73,9 +101,9 @@ function CategoryPickerModal({ anchorPosition={anchorPosition} popoverDimensions={popoverDimensions} anchorAlignment={anchorAlignment} - innerContainerStyle={StyleUtils.getWidthStyle(popoverDimensions.width)} + innerContainerStyle={isSmallScreenWidth ? undefined : StyleUtils.getWidthStyle(popoverDimensions.width)} restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} - shouldSwitchPositionIfOverflow + shouldSwitchPositionIfOverflow={shouldSwitchPositionIfOverflow} shouldEnableNewFocusManagement shouldMeasureAnchorPositionFromTop={shouldMeasureAnchorPositionFromTop} shouldSkipRemeasurement @@ -84,6 +112,7 @@ function CategoryPickerModal({ > void; }; const getSelectedOptions = (selectedCategory?: string): Category[] => { @@ -55,7 +64,15 @@ const getSelectedOptions = (selectedCategory?: string): Category[] => { ]; }; -function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOption = false, addBottomSafeAreaPadding = false, shouldAutoFocusSearchInput = false}: CategoryPickerProps) { +function CategoryPicker({ + selectedCategory, + policyID, + onSubmit, + shouldShowNoneOption = false, + addBottomSafeAreaPadding = false, + shouldAutoFocusSearchInput = false, + onRenderedRowCountChange, +}: CategoryPickerProps) { const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); const [shouldShowGLCode] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`, { @@ -66,6 +83,9 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt const [policyRecentlyUsedCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); const {isOffline} = useNetwork(); + // Backfill the policy's categories on demand so lazy-loaded accounts don't get stuck showing only the selected category. + const {isLoadingPolicyCategories} = useLoadPolicyCategories(policyID); + const {translate, localeCompare} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; @@ -105,10 +125,24 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt const sectionsWithNoneOption = noneOption.length > 0 ? [...sections.slice(0, selectedCategorySectionIndex + 1), noneOptionSection, ...sections.slice(selectedCategorySectionIndex + 1)] : sections; + const sectionsWithTitleStyles = sectionsWithNoneOption.map((section) => ({...section, data: section.data.map((category) => ({...category, titleStyles: styles.w100}))})); const categoryData = sectionsWithNoneOption.flatMap((section) => section.data); + const renderedRowCount = sectionsWithNoneOption.reduce((total, section) => total + section.data.length + (section.title ? 1 : 0), 0); + + useEffect(() => { + if (debouncedSearchValue) { + return; + } + onRenderedRowCountChange?.(renderedRowCount); + }, [renderedRowCount, debouncedSearchValue, onRenderedRowCountChange]); + const categoriesCount = getEnabledCategoriesCount(categories); const selectedOptionKey = categoryData.find((category) => category.searchText === selectedCategory)?.keyForList; + // While the on-demand fetch above is in flight, show the list skeleton instead of flashing the selected-only + // fallback. A draft collection is a complete local list, so it renders immediately rather than waiting on the read. + const isLoadingNewOptions = isLoadingPolicyCategories && policyCategoriesDraft === undefined; + const textInputOptions = { value: searchValue, label: translate('common.search'), @@ -122,15 +156,17 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt return ( () : sectionsWithTitleStyles} onSelectRow={onSubmit} ListItem={SingleSelectListItem} shouldShowTextInput={categoriesCount >= CONST.STANDARD_LIST_ITEM_LIMIT} textInputOptions={textInputOptions} + shouldShowLoadingPlaceholder={isLoadingNewOptions} + isLoadingNewOptions={isLoadingNewOptions} initiallyFocusedItemKey={selectedOptionKey} addBottomSafeAreaPadding={addBottomSafeAreaPadding} - style={{listItemTitleStyles: styles.w100}} - isRowMultilineSupported titleNumberOfLines={CONST.TRANSACTION_TAG_AND_CATEGORY_PICKER_MAX_TITLE_LINES} /> ); diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 6bdcabfbf3e7..d1fc94719d12 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -1,12 +1,69 @@ -import {ChartFontsProvider} from '@components/Charts/hooks'; +import BAR_INNER_PADDING, {VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants'; +import {ChartFontsProvider, useChartFontManager, useChartLabelFormats, useChartLabelLayout, useChartLabelMeasurements} from '@components/Charts/hooks'; +import {getVerticalBarLabelLayoutInputs, getVerticalBarPlotBounds, getYAxisLabelWidth} from '@components/Charts/utils'; +import {GLYPH_PADDING, LABEL_ROTATIONS} from '@components/Charts/VictoryTheme'; + +import variables from '@styles/variables'; + +import type {LayoutChangeEvent} from 'react-native'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; import type {BarChartContentProps} from './types'; import HorizontalBarChartContentBody from './HorizontalBarChartContent'; import VerticalBarChartContentBody from './VerticalBarChartContent'; -function BarChartContent({isHorizontal = false, ...props}: BarChartContentProps) { - return {isHorizontal ? : }; +const FONT_SIZE = variables.iconSizeExtraSmall; + +/** + * Resolves the bar chart orientation on every layout change and renders the matching body. Wide layouts pass + * `isHorizontal`. Narrow layouts predict the vertical chart's label fit from the container width, so the chart + * switches to horizontal bars when labels don't fit even at 45° and back to vertical when the container grows. + */ +function BarChartOrientationDispatcher({isHorizontal = false, canFallBackToHorizontalBars = false, ...props}: BarChartContentProps) { + const fontManager = useChartFontManager(); + const [containerWidth, setContainerWidth] = useState(0); + const {data, yAxisUnit, yAxisUnitPosition = 'left'} = props; + + const {formatCompactValue} = useChartLabelFormats({data, unit: yAxisUnit, unitPosition: yAxisUnitPosition}); + const measurements = useChartLabelMeasurements(data, fontManager, FONT_SIZE); + + // Predict the vertical chart's plot geometry from the container width so the fit decision matches what it would measure after mounting. + const yAxisLabelWidth = getYAxisLabelWidth(data, formatCompactValue, fontManager, FONT_SIZE, VERTICAL_BAR_DOMAIN_PADDING); + const plotBounds = getVerticalBarPlotBounds(containerWidth, yAxisLabelWidth + GLYPH_PADDING); + + const {labelRotation} = useChartLabelLayout({ + data, + fontManager, + fontSize: FONT_SIZE, + measurements, + ...getVerticalBarLabelLayoutInputs({ + containerWidth, + plotLeft: plotBounds.left, + plotRight: plotBounds.right, + plotWidth: plotBounds.width, + dataLength: data.length, + innerPadding: BAR_INNER_PADDING, + }), + }); + + const renderHorizontal = isHorizontal || (canFallBackToHorizontalBars && labelRotation === LABEL_ROTATIONS.VERTICAL); + + const handleLayout = (event: LayoutChangeEvent) => { + setContainerWidth(event.nativeEvent.layout.width); + }; + + return {renderHorizontal ? : }; +} + +function BarChartContent(props: BarChartContentProps) { + return ( + + + + ); } export default BarChartContent; diff --git a/src/components/Charts/BarChart/HorizontalBarChartContent.tsx b/src/components/Charts/BarChart/HorizontalBarChartContent.tsx index eae09d67fd3c..277b4dd6415c 100644 --- a/src/components/Charts/BarChart/HorizontalBarChartContent.tsx +++ b/src/components/Charts/BarChart/HorizontalBarChartContent.tsx @@ -1,11 +1,12 @@ import ActivityIndicator from '@components/ActivityIndicator'; +import {HORIZONTAL_BAR_DOMAIN_PADDING, MIN_BAR_ROW_HEIGHT} from '@components/Charts/barChartConstants'; import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; import type {HitTestArgs, ResolveTargetIndexArgs} from '@components/Charts/hooks'; import {useChartFontManager, useChartInteractions, useChartLabelFormats, useChartParagraphs} from '@components/Charts/hooks'; import {findClosestPoint} from '@components/Charts/hooks/useChartInteractions'; -import {calculateMinDomainPadding, getFontLineMetrics, getNiceValueDomain, getNiceValueTicks, measureTextWidth} from '@components/Charts/utils'; -import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING, LABEL_PADDING, MAX_Y_AXIS_LABEL_WIDTH} from '@components/Charts/VictoryTheme'; +import {getFontLineMetrics, getHorizontalChartHeight, getNiceValueDomain, getNiceValueTicks, measureTextWidth} from '@components/Charts/utils'; +import VictoryTheme, {CATEGORY_LABEL_WIDTH_RATIO, CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING, LABEL_PADDING, MAX_Y_AXIS_LABEL_WIDTH} from '@components/Charts/VictoryTheme'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -26,9 +27,6 @@ import {CartesianChart} from 'victory-native'; import type {BarChartProps} from './types'; -/** Extra pixel spacing between the chart boundary and the data range. `right` keeps the longest bar's tip and its tooltip off the edge. */ -const BASE_DOMAIN_PADDING = {top: 8, bottom: 8, left: 0, right: 8}; - /** Gap between the bar tip and the tooltip pointer, lifting the tooltip clear of the bar. */ const TOOLTIP_TIP_GAP = 4; @@ -157,7 +155,6 @@ function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPos const styles = useThemeStyles(); const fontManager = useChartFontManager(); const [chartWidth, setChartWidth] = useState(0); - const [barAreaHeight, setBarAreaHeight] = useState(0); // Transpose: value on the x-axis, category index on the y-axis. // Categories are reversed (index 0 mapped to the top row) so a descending-sorted ranking reads top-to-bottom. @@ -169,7 +166,7 @@ function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPos const valueDomain = getNiceValueDomain(data, VictoryTheme.axis.tickCount); - const {formatValue} = useChartLabelFormats({ + const {formatValue, formatCompactValue} = useChartLabelFormats({ data, unit: yAxisUnit, unitPosition: yAxisUnitPosition, @@ -189,14 +186,6 @@ function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPos setChartWidth(event.nativeEvent.layout.width); }; - const domainPadding = (() => { - if (barAreaHeight === 0) { - return BASE_DOMAIN_PADDING; - } - const verticalPadding = calculateMinDomainPadding(barAreaHeight, data.length, HORIZONTAL_BAR_PADDING); - return {...BASE_DOMAIN_PADDING, top: verticalPadding, bottom: verticalPadding}; - })(); - const barThickness = useSharedValue(0); const rowHeight = useSharedValue(0); const xZero = useSharedValue(0); @@ -204,7 +193,6 @@ function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPos const handleChartBoundsChange = (bounds: ChartBounds) => { const plotHeight = bounds.bottom - bounds.top; - setBarAreaHeight(plotHeight); barThickness.set(data.length > 0 ? (1 - HORIZONTAL_BAR_PADDING) * (plotHeight / data.length) : 0); plotLeft.set(bounds.left); }; @@ -288,6 +276,10 @@ function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPos cursor: isCursorOverClickable.get() ? 'pointer' : 'auto', })); + // Cap the label column as a fraction of the width so long labels can't starve the plot on narrow containers. + // Shared by the left padding and ChartYAxisLabels truncation so the gutter and rendered labels agree. + const categoryLabelMaxWidth = chartWidth > 0 ? Math.min(MAX_Y_AXIS_LABEL_WIDTH, CATEGORY_LABEL_WIDTH_RATIO * chartWidth) : MAX_Y_AXIS_LABEL_WIDTH; + const categoryLabelWidth = (() => { if (!fontManager || data.length === 0) { return 0; @@ -296,7 +288,7 @@ function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPos for (const point of data) { widest = Math.max(widest, measureTextWidth(point.label, fontManager, variables.iconSizeExtraSmall)); } - return Math.min(MAX_Y_AXIS_LABEL_WIDTH, widest); + return Math.min(categoryLabelMaxWidth, widest); })(); const {ascent, descent} = fontManager ? getFontLineMetrics(fontManager, variables.iconSizeExtraSmall) : {ascent: 0, descent: 0}; @@ -312,7 +304,7 @@ function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPos } let widest = 0; for (const tick of getNiceValueTicks(valueDomain, VictoryTheme.axis.tickCount)) { - widest = Math.max(widest, measureTextWidth(formatValue(tick), fontManager, variables.iconSizeExtraSmall)); + widest = Math.max(widest, measureTextWidth(formatCompactValue(tick), fontManager, variables.iconSizeExtraSmall)); } return widest / 2; })(); @@ -333,7 +325,7 @@ function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPos fontSize={variables.iconSizeExtraSmall} fontManager={fontManager} labelColor={theme.textSupporting} - formatValue={formatValue} + formatValue={formatCompactValue} /> point.y)} @@ -343,7 +335,7 @@ function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPos fontManager={fontManager} labelColor={theme.textSupporting} formatValue={(yValue: number) => data.at(lastIndex - yValue)?.label ?? ''} - labelGap={CATEGORY_LABEL_GAP} + maxLabelWidth={categoryLabelMaxWidth} leftAlign avoidOverlap /> @@ -351,14 +343,18 @@ function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPos ); }; - const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; const chartPadding = { ...VictoryTheme.axis.padding, + top: 0, right: VictoryTheme.axis.padding.right + valueLabelRightGutter, - bottom: labelSpace + VictoryTheme.axis.padding.bottom, + // Just enough to hold the axis-label gap and the label itself. The card's own bottom padding sits below the + // canvas, so no extra padding is needed here and the labels stay a card-padding's distance from the edge. + bottom: labelSpace, left: categoryLabelWidth + CATEGORY_LABEL_GAP + GLYPH_PADDING, }; + const dynamicChartStyle = {height: getHorizontalChartHeight(data.length, MIN_BAR_ROW_HEIGHT, chartPadding.top + chartPadding.bottom, CHART_CONTENT_MIN_HEIGHT + labelSpace)}; + // Draw each bar as its own Skia path so the rounded pill sits on the value tip and the axis end stays square, // for both positive (right-pointing) and negative (left-pointing) bars. thickness mirrors BarGroup's own // barWidth for a single series: (1 - betweenGroupPadding) * plotHeight / groupCount. @@ -416,7 +412,7 @@ function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPos padding={chartPadding} yKeys={['y']} domain={valueDomain ? {x: valueDomain} : undefined} - domainPadding={domainPadding} + domainPadding={HORIZONTAL_BAR_DOMAIN_PADDING} onChartBoundsChange={handleChartBoundsChange} onScaleChange={handleScaleChange} renderOutside={renderOutside} diff --git a/src/components/Charts/BarChart/VerticalBarChartContent.tsx b/src/components/Charts/BarChart/VerticalBarChartContent.tsx index 0fa24cdd9c87..4c10e2fb0cdd 100644 --- a/src/components/Charts/BarChart/VerticalBarChartContent.tsx +++ b/src/components/Charts/BarChart/VerticalBarChartContent.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import BAR_INNER_PADDING from '@components/Charts/barChartConstants'; +import BAR_INNER_PADDING, {VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants'; import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; @@ -13,7 +13,7 @@ import { useDynamicYDomain, useLabelHitTesting, } from '@components/Charts/hooks'; -import {calculateMinDomainPadding, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; +import {calculateMinDomainPadding, getVerticalBarLabelLayoutInputs, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; import useTheme from '@hooks/useTheme'; @@ -32,11 +32,6 @@ import {Bar, CartesianChart} from 'victory-native'; import type {BarChartProps} from './types'; -/** Extra pixel spacing between the chart boundary and the data range, applied per side (Victory's `domainPadding` prop) - * We need bottom: 1 for proper display of the bottom label - */ -const BASE_DOMAIN_PADDING = {top: 32, bottom: 1, left: 0, right: 0}; - function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', color, onBarPress}: BarChartProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -69,15 +64,12 @@ function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosit const domainPadding = (() => { if (chartWidth === 0) { - return BASE_DOMAIN_PADDING; + return VERTICAL_BAR_DOMAIN_PADDING; } const horizontalPadding = calculateMinDomainPadding(chartWidth, data.length, BAR_INNER_PADDING); - return {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding}; + return {...VERTICAL_BAR_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding}; })(); - const totalDomainPadding = domainPadding.left + domainPadding.right; - const paddingScale = barAreaWidth > 0 ? barAreaWidth / (barAreaWidth + totalDomainPadding) : 0; - const originalLabels = data.map(getXAxisLabel); const measurements = useChartLabelMeasurements(data, fontManager, variables.iconSizeExtraSmall); @@ -86,14 +78,18 @@ function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosit data, fontManager, fontSize: variables.iconSizeExtraSmall, - tickSpacing: barAreaWidth > 0 ? barAreaWidth / data.length : 0, - labelAreaWidth: barAreaWidth, - firstTickLeftSpace: boundsLeft + domainPadding.left * paddingScale, - lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + domainPadding.right * paddingScale : 0, measurements, + ...getVerticalBarLabelLayoutInputs({ + containerWidth: chartWidth, + plotLeft: boundsLeft, + plotRight: boundsRight, + plotWidth: barAreaWidth, + dataLength: data.length, + innerPadding: BAR_INNER_PADDING, + }), }); - const {formatValue} = useChartLabelFormats({ + const {formatValue, formatCompactValue} = useChartLabelFormats({ data, unit: yAxisUnit, unitPosition: yAxisUnitPosition, @@ -211,7 +207,7 @@ function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosit fontSize={variables.iconSizeExtraSmall} fontManager={fontManager} labelColor={theme.textSupporting} - formatValue={formatValue} + formatValue={formatCompactValue} leftAlign /> @@ -220,7 +216,7 @@ function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosit const labelSpace = VictoryTheme.axis.labelGap + (xAxisLabelHeight ?? 0); const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; - const yAxisLabelWidth = getYAxisLabelWidth(data, formatValue, fontManager, variables.iconSizeExtraSmall, BASE_DOMAIN_PADDING); + const yAxisLabelWidth = getYAxisLabelWidth(data, formatCompactValue, fontManager, variables.iconSizeExtraSmall, VERTICAL_BAR_DOMAIN_PADDING); const chartPadding = {...VictoryTheme.axis.padding, bottom: labelSpace + VictoryTheme.axis.padding.bottom, left: yAxisLabelWidth + GLYPH_PADDING}; if (isLoading || !fontManager) { @@ -256,6 +252,9 @@ function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosit xAxis={{ tickCount: data.length, lineWidth: VictoryTheme.axis.xLineWidth, + // "outset" makes victory-native reserve 2 * yAxis.labelOffset below the plot for labels it + // doesn't draw (we render ChartXAxisLabels ourselves), on top of our own labelSpace. + labelPosition: 'inset', }} yAxis={[ { diff --git a/src/components/Charts/BarChart/index.native.tsx b/src/components/Charts/BarChart/index.native.tsx index b85c3b65074d..16a2c0787c93 100644 --- a/src/components/Charts/BarChart/index.native.tsx +++ b/src/components/Charts/BarChart/index.native.tsx @@ -7,13 +7,14 @@ import type {BarChartProps} from './types'; import BarChartContent from './BarChartContent'; function BarChart(props: BarChartProps) { - // Horizontal bars on wide layouts, vertical on narrow (mobile/RHP). - const {isHorizontal} = useBarChartOrientation(); + // Horizontal bars on wide layouts, vertical on narrow (mobile/RHP) unless labels don't fit. + const {isHorizontal, canFallBackToHorizontalBars} = useBarChartOrientation(); return ( ); } diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index db4696a25ce7..1dd7cd8d9cab 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -8,13 +8,13 @@ import type {BarChartProps} from './types'; const getBarChartContent = () => import('./BarChartContent'); function BarChart(props: BarChartProps) { - // Horizontal bars on wide layouts, vertical on narrow (mobile/RHP). A single lazy module receives orientation as a prop. - const {isHorizontal} = useBarChartOrientation(); + // Horizontal bars on wide layouts, vertical on narrow (mobile/RHP) unless labels don't fit. A single lazy module receives orientation as a prop. + const {isHorizontal, canFallBackToHorizontalBars} = useBarChartOrientation(); return ( ); } diff --git a/src/components/Charts/BarChart/types.ts b/src/components/Charts/BarChart/types.ts index eab5e92441f6..201fb9d6aaa9 100644 --- a/src/components/Charts/BarChart/types.ts +++ b/src/components/Charts/BarChart/types.ts @@ -11,6 +11,9 @@ type BarChartProps = CartesianChartProps & { type BarChartContentProps = BarChartProps & { /** When true, renders horizontal bars (value on the x-axis) instead of the default vertical bars. */ isHorizontal?: boolean; + + /** When true, the vertical chart may switch to horizontal bars if category labels don't fit even at 45° (narrow layout, beta on). */ + canFallBackToHorizontalBars?: boolean; }; export type {BarChartProps, BarChartContentProps}; diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index f1320357b7c0..8fee71050a1f 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -82,13 +82,13 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = ' const measurements = useChartLabelMeasurements(data, fontManager, variables.iconSizeExtraSmall); const {lineHeight, firstLabelWidth, lastLabelWidth, maxLabelWidth, labelWidths} = measurements; - const {formatValue} = useChartLabelFormats({ + const {formatValue, formatCompactValue} = useChartLabelFormats({ data, unit: yAxisUnit, unitPosition: yAxisUnitPosition, }); - const yAxisLabelWidth = getYAxisLabelWidth(data, formatValue, fontManager, variables.iconSizeExtraSmall, BASE_DOMAIN_PADDING); + const yAxisLabelWidth = getYAxisLabelWidth(data, formatCompactValue, fontManager, variables.iconSizeExtraSmall, BASE_DOMAIN_PADDING); const tickSpacing = plotAreaWidth > 0 && data.length > 0 ? plotAreaWidth / data.length : 0; const chartPaddingLeft = yAxisLabelWidth + GLYPH_PADDING; @@ -214,7 +214,7 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = ' fontSize={variables.iconSizeExtraSmall} fontManager={fontManager} labelColor={theme.textSupporting} - formatValue={formatValue} + formatValue={formatCompactValue} leftAlign /> )} @@ -263,6 +263,9 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = ' xAxis={{ tickCount: data.length, lineWidth: VictoryTheme.axis.xLineWidth, + // "outset" makes victory-native reserve 2 * yAxis.labelOffset below the plot for labels it + // doesn't draw (we render ChartXAxisLabels ourselves), on top of our own labelSpace. + labelPosition: 'inset', }} yAxis={[ { diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index 4f261f1919bb..180542eea001 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -139,7 +139,7 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice return ( { tooltipPosition.set(slice.tooltipPosition); setActiveSliceIndex(slice.ordinalIndex); diff --git a/src/components/Charts/SkiaWebChart/index.tsx b/src/components/Charts/SkiaWebChart/index.tsx index 781d6f18354e..8268fa6de3c9 100644 --- a/src/components/Charts/SkiaWebChart/index.tsx +++ b/src/components/Charts/SkiaWebChart/index.tsx @@ -6,14 +6,19 @@ import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import Log from '@libs/Log'; + import variables from '@styles/variables'; +import type {TranslationPaths} from '@src/languages/types'; import viewRef from '@src/types/utils/viewRef'; import type {ComponentType} from 'react'; +import * as Sentry from '@sentry/react-native'; import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; import React, {useRef, useState} from 'react'; +import {ErrorBoundary} from 'react-error-boundary'; import {View} from 'react-native'; import isSkiaWebSupported from './isSkiaWebSupported'; @@ -29,7 +34,19 @@ type SkiaWebChartProps = { /** Identifies the loading skeleton span for telemetry. */ }; -function ChartUnavailable() { +type ChartUnavailableProps = { + /** Explains why the chart can't be shown. Defaults to the WebGL message. */ + description?: TranslationPaths; +}; + +/** + * The CanvasKit glue asks `locateFile` where to fetch `canvaskit.wasm` from. The binary is emitted under a + * versioned name (see `CANVASKIT_WASM_FILENAME` in `config/rsbuild/rsbuild.common.ts`) so this bundle's glue + * can never be handed another release's binary. Every other file keeps the default root-relative path. + */ +const locateCanvasKitFile = (file: string) => (file === 'canvaskit.wasm' ? __CANVASKIT_WASM_URL__ : `/${file}`); + +function ChartUnavailable({description = 'common.webGLNotSupported'}: ChartUnavailableProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['MonitorSyncNo']); @@ -42,15 +59,37 @@ function ChartUnavailable() { height={variables.iconSizeSuperLarge} /> {translate('common.unableToDisplayChart')} - {translate('common.webGLNotSupported')} + {translate(description)} ); } +function ChartFailedToLoad() { + return ; +} + +/** + * CanvasKit failing to initialize is environmental rather than a bug in the chart: a stale or mismatched + * `canvaskit.wasm` (see https://github.com/Expensify/App/issues/102042), an 8 MB download failing offline, + * or a chunk that vanished from the CDN. Report it as a warning under one fingerprint so it stays visible in + * Sentry without fragmenting per chart, and let the boundary show the empty state instead of crashing. + */ +const logSkiaLoadError = (error: Error, info: {componentStack?: string | null}) => { + const componentStack = info.componentStack ?? undefined; + Log.warn(`[SkiaWebChart] failed to load CanvasKit - ${error.message}`, {componentStack}); + Sentry.captureException(error, { + level: 'warning', + tags: {context: 'skia-web-chart-load'}, + fingerprint: ['skia-web-chart-load-failure'], + extra: {componentStack}, + }); +}; + /** * Shared web wrapper around `WithSkiaWeb` for the chart entry points (Pie/Line/Bar and the Victory * renderer). When the environment can't provide a usable WebGL/Skia surface it shows an "unable to * display chart" empty state instead of mounting Skia, avoiding the CanvasKit GL-init crash (see `isSkiaWebSupported`). + * If CanvasKit itself fails to load or link, the error boundary degrades to the same empty state. */ // `object` mirrors WithSkiaWeb's own constraint; `Record` would reject the // interface-based render-html renderer props (VictoryChartRendererProps) that lack an index signature. @@ -83,12 +122,18 @@ function SkiaWebChart({getComponent, componentProps}: Ski ref={viewRef(containerRef)} style={styles.mw100} > - `/${file}`}} - getComponent={getComponent} - componentProps={componentProps} - fallback={fallback} - /> + + {/* The glue mutates `opts` into the module object, so give each mount its own object rather than sharing one. */} + + ); } diff --git a/src/components/Charts/VictoryTheme.ts b/src/components/Charts/VictoryTheme.ts index b11720661f13..02d089d2f98b 100644 --- a/src/components/Charts/VictoryTheme.ts +++ b/src/components/Charts/VictoryTheme.ts @@ -119,6 +119,10 @@ const DIAGONAL_ANGLE_RADIAN_THRESHOLD = 1; // Maximum width for Y-axis labels in pixels const MAX_Y_AXIS_LABEL_WIDTH = 200; +// Max fraction of the chart width the horizontal chart's category labels may occupy, so the plot +// stays usable on narrow containers where the fixed MAX_Y_AXIS_LABEL_WIDTH would starve it. +const CATEGORY_LABEL_WIDTH_RATIO = 0.4; + // Maximum width for X-axis labels in pixels const MAX_X_AXIS_LABEL_WIDTH = 500; @@ -137,6 +141,7 @@ export { DIAGONAL_ANGLE_RADIAN_THRESHOLD, MAX_X_AXIS_LABEL_WIDTH, MAX_Y_AXIS_LABEL_WIDTH, + CATEGORY_LABEL_WIDTH_RATIO, GLYPH_PADDING, }; export default VictoryTheme; diff --git a/src/components/Charts/barChartConstants.ts b/src/components/Charts/barChartConstants.ts index ccba322c2f1a..13018f275c0a 100644 --- a/src/components/Charts/barChartConstants.ts +++ b/src/components/Charts/barChartConstants.ts @@ -1,4 +1,14 @@ /** Inner padding between bars (0.3 = 30% of bar width) */ const BAR_INNER_PADDING = 0.3; +/** Minimum vertical space (px) per category row in a horizontal bar chart, so labels show in full. */ +const MIN_BAR_ROW_HEIGHT = 36; + +/** Per-side domain padding (Victory's `domainPadding`). `bottom: 1` keeps the bottom label visible. */ +const VERTICAL_BAR_DOMAIN_PADDING = {top: 32, bottom: 1, left: 0, right: 0}; + +/** Per-side domain padding (Victory's `domainPadding`). `right` keeps the longest bar's tip and tooltip off the edge. */ +const HORIZONTAL_BAR_DOMAIN_PADDING = {top: 8, bottom: 8, left: 0, right: 8}; + export default BAR_INNER_PADDING; +export {MIN_BAR_ROW_HEIGHT, VERTICAL_BAR_DOMAIN_PADDING, HORIZONTAL_BAR_DOMAIN_PADDING}; diff --git a/src/components/Charts/components/ChartYAxisLabels.tsx b/src/components/Charts/components/ChartYAxisLabels.tsx index a01d8426ce4f..d8f92f1e99a3 100644 --- a/src/components/Charts/components/ChartYAxisLabels.tsx +++ b/src/components/Charts/components/ChartYAxisLabels.tsx @@ -1,6 +1,6 @@ import {useChartParagraphs} from '@components/Charts/hooks'; import {getFontLineMetrics, measureTextWidth, truncateLabel} from '@components/Charts/utils'; -import VictoryTheme, {ELLIPSIS, GLYPH_PADDING, LABEL_PADDING, MAX_X_AXIS_LABEL_WIDTH, MAX_Y_AXIS_LABEL_WIDTH} from '@components/Charts/VictoryTheme'; +import {ELLIPSIS, GLYPH_PADDING, LABEL_PADDING, MAX_X_AXIS_LABEL_WIDTH, MAX_Y_AXIS_LABEL_WIDTH} from '@components/Charts/VictoryTheme'; import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; import type {ChartBounds, Scale} from 'victory-native'; @@ -33,11 +33,11 @@ type ChartYAxisLabelsProps = { /** When true, labels are left-aligned starting at the left edge of the chart instead of right-aligned. */ leftAlign?: boolean; - /** Horizontal gap between the labels and the chart's left edge. Defaults to the shared axis gap. */ - labelGap?: number; - /** When true, thin out labels so vertically stacked rows never overlap (used by the horizontal chart's category axis). */ avoidOverlap?: boolean; + + /** Width (px) labels truncate to. Must match the left padding the caller reserves for them. */ + maxLabelWidth?: number; }; function ChartYAxisLabels({ @@ -49,19 +49,18 @@ function ChartYAxisLabels({ labelColor, formatValue, leftAlign = false, - labelGap = VictoryTheme.axis.labelGap, avoidOverlap = false, + maxLabelWidth = MAX_Y_AXIS_LABEL_WIDTH, }: ChartYAxisLabelsProps) { const formattedLabels = yTicks.map((tick) => formatValue(tick)); // Truncate to a single line: labels wider than the max would otherwise wrap onto a second line and // overflow the row height (positioning below assumes one line). const ellipsisWidth = measureTextWidth(ELLIPSIS, fontManager, fontSize); - const truncatedLabels = formattedLabels.map((label) => truncateLabel(label, measureTextWidth(label, fontManager, fontSize), MAX_Y_AXIS_LABEL_WIDTH, ellipsisWidth)); + const truncatedLabels = formattedLabels.map((label) => truncateLabel(label, measureTextWidth(label, fontManager, fontSize), maxLabelWidth, ellipsisWidth)); // Lay out at the wide width so the already-truncated labels never wrap. const paragraphs = useChartParagraphs(truncatedLabels, fontManager, fontSize, labelColor, MAX_X_AXIS_LABEL_WIDTH); - const maxWidth = Math.max(0, ...paragraphs.map((item) => item.width)); const {ascent, descent} = getFontLineMetrics(fontManager, fontSize); const lineHeight = ascent + descent; @@ -94,7 +93,9 @@ function ChartYAxisLabels({ return null; } - const x = chartBounds.left - labelGap + GLYPH_PADDING - (leftAlign ? maxWidth : paraData.width); + // Left-aligned labels (horizontal bar chart) start at the canvas origin so they sit on the same left + // key line as the chart title. Right-aligned labels end just inside the plot's left edge. + const x = leftAlign ? 0 : chartBounds.left + GLYPH_PADDING - paraData.width; const tickY = yScale(tick); return ( diff --git a/src/components/Charts/hooks/useChartLabelFormats.ts b/src/components/Charts/hooks/useChartLabelFormats.ts index b05e02c166bf..f025e2c85ef0 100644 --- a/src/components/Charts/hooks/useChartLabelFormats.ts +++ b/src/components/Charts/hooks/useChartLabelFormats.ts @@ -21,14 +21,15 @@ export default function useChartLabelFormats({data, unit, unitPosition = 'left', const displayUnit = typeof unit === 'string' ? unit : unit?.value; const unitToDisplay = fontManager && !canFontRenderText(displayUnit, fontManager) ? (unit as UnitWithFallback)?.fallback : displayUnit; - const formatValue = (value: number) => { - const formatted = numberFormat(value); + const withUnit = (formatted: string) => { if (!unitToDisplay) { return formatted; } const separator = unitToDisplay.length > 1 ? ' ' : ''; return unitPosition === 'left' ? `${unitToDisplay}${separator}${formatted}` : `${formatted}${separator}${unitToDisplay}`; }; + const formatValue = (value: number) => withUnit(numberFormat(value)); + const formatCompactValue = (value: number) => withUnit(numberFormat(value, {notation: 'compact'}).replace('K', 'k')); const formatLabel = (value: number) => { const index = Math.round(value); @@ -45,5 +46,6 @@ export default function useChartLabelFormats({data, unit, unitPosition = 'left', return { formatLabel, formatValue, + formatCompactValue, }; } diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 804cfa0b8070..2faa7776b4da 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -61,7 +61,8 @@ function useChartLabelLayout({data, fontManager, tickSpacing, labelAreaWidth, fi const effectiveFirstTickLeftSpace = data.length === 1 ? Infinity : firstTickLeftSpace; const effectiveLastTickRightSpace = data.length === 1 ? Infinity : lastTickRightSpace; - // Pick rotation (prefer 0° → 45° → 90°) + // Pick rotation (prefer 0° → 45° → 90°). When neither 0° nor 45° fits, rotation stays 90° and bar-chart + // callers read that as the signal to fall back to horizontal bars. let rotation: LabelRotation = LABEL_ROTATIONS.VERTICAL; const hWidth = effectiveWidth(maxLabelWidth, lineHeight, LABEL_ROTATIONS.HORIZONTAL); diff --git a/src/components/Charts/utils/index.ts b/src/components/Charts/utils/index.ts index b4bab37aa2ec..4857079332ad 100644 --- a/src/components/Charts/utils/index.ts +++ b/src/components/Charts/utils/index.ts @@ -101,7 +101,8 @@ function canFontRenderText(text: string | undefined, fontManager: SkTypefaceFont */ function measureTextWidth(text: string, fontManager: SkTypefaceFontProvider, fontSize: number): number { const para = buildChartParagraph(text, fontManager, fontSize); - para.layout(MAX_X_AXIS_LABEL_WIDTH); + // Unbounded width so text never wraps; a wrapped getLongestLine would underestimate long labels. + para.layout(Number.MAX_SAFE_INTEGER); return para.getLongestLine(); } @@ -463,6 +464,55 @@ function getNiceValueTicks(domain: [number, number], tickCount: number): number[ return scaleLinear().domain(domain).ticks(tickCount); } +/** + * Horizontal plot bounds of a vertical bar chart for a container width, mirroring victory-native's layout. + * Deriving it from the width (not post-mount) lets the wrapper re-decide orientation on every resize. + */ +function getVerticalBarPlotBounds(chartWidth: number, paddingLeft: number): {left: number; right: number; width: number} { + const left = paddingLeft + VictoryTheme.axis.labelGap; + const right = Math.max(left, chartWidth - VictoryTheme.axis.padding.right); + return {left, right, width: right - left}; +} + +/** + * Layout inputs shared by the vertical bar chart body and the orientation dispatcher, derived from the plot bounds. + * The body passes its measured bounds and the dispatcher passes bounds predicted from the container width, so both + * feed `useChartLabelLayout` the exact same geometry and cannot drift apart. + */ +function getVerticalBarLabelLayoutInputs({ + containerWidth, + plotLeft, + plotRight, + plotWidth, + dataLength, + innerPadding, +}: { + containerWidth: number; + plotLeft: number; + plotRight: number; + plotWidth: number; + dataLength: number; + innerPadding: number; +}): {tickSpacing: number; labelAreaWidth: number; firstTickLeftSpace: number; lastTickRightSpace: number} { + const domainPadding = containerWidth > 0 && dataLength > 0 ? calculateMinDomainPadding(containerWidth, dataLength, innerPadding) : 0; + const paddingScale = plotWidth > 0 ? plotWidth / (plotWidth + 2 * domainPadding) : 0; + return { + tickSpacing: plotWidth > 0 && dataLength > 0 ? plotWidth / dataLength : 0, + labelAreaWidth: plotWidth, + firstTickLeftSpace: plotLeft + domainPadding * paddingScale, + lastTickRightSpace: containerWidth > 0 ? containerWidth - plotRight + domainPadding * paddingScale : 0, + }; +} + +/** + * Height of a horizontal bar chart. Grows with the row count so every category row gets at least + * `minRowHeight` of vertical space, keeping all category labels visible instead of thinning them out. + * Never smaller than `minHeight`, so small datasets keep the shared minimum. + */ +function getHorizontalChartHeight(rowCount: number, minRowHeight: number, verticalPadding: number, minHeight: number): number { + return Math.max(minHeight, rowCount * minRowHeight + verticalPadding); +} + /** Returns the pixel width needed for Y-axis labels given the chart data. */ function getYAxisLabelWidth( data: ChartDataPoint[], @@ -512,6 +562,9 @@ export { getNiceValueDomain, getNiceValueTicks, getYAxisLabelWidth, + getHorizontalChartHeight, + getVerticalBarPlotBounds, + getVerticalBarLabelLayoutInputs, }; export type {ChartLabelHitTestParams}; diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index c98ec9f3ad10..0672715a273a 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -8,7 +8,7 @@ import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop, {shouldMoveInitialSelectionToTop} from '@libs/SelectionListOrderUtils'; import CONST from '@src/CONST'; @@ -39,7 +39,18 @@ function YearPickerModal({isVisible, years, currentYear, onYearChange, onClose, const initialYear = useInitialSelection(resolvedCurrentYear, {isVisible}); // Pin the frozen initial year to the top of the full sorted list before search filtering, so it stays pinned while searching. // Copy before sorting so we don't mutate the caller's `years` prop during render. - const sortedYears = [...years].sort((a, b) => b.value - a.value); + // Long lists (where the pin applies) show upcoming years ascending, then past years nearest first, so the row after the pinned year is the next year. + // Short lists aren't pinned, so they keep the newest-first order. + const compareNewestFirst = (a: CalendarPickerListItem, b: CalendarPickerListItem) => b.value - a.value; + const compareAroundInitialYear = (a: CalendarPickerListItem, b: CalendarPickerListItem) => { + const isAUpcoming = a.value > initialYear; + const isBUpcoming = b.value > initialYear; + if (isAUpcoming !== isBUpcoming) { + return isAUpcoming ? -1 : 1; + } + return isAUpcoming ? a.value - b.value : b.value - a.value; + }; + const sortedYears = [...years].sort(shouldMoveInitialSelectionToTop(years.length) ? compareAroundInitialYear : compareNewestFirst); const orderedYears = moveInitialSelectionToTop(sortedYears, [String(initialYear)]); const data = searchText === '' ? orderedYears : orderedYears.filter((year) => year.text?.includes(searchText)); const headerMessage = !data.length ? translate('common.noResultsFound') : ''; diff --git a/src/components/DistanceEReceipt.tsx b/src/components/DistanceEReceipt.tsx index 7cf18bc0a319..b489c9dfccbd 100644 --- a/src/components/DistanceEReceipt.tsx +++ b/src/components/DistanceEReceipt.tsx @@ -1,28 +1,12 @@ -import EReceiptBackground from '@assets/images/eReceipt_background.svg'; - -import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; -import {getTransactionDetails} from '@libs/ReportUtils'; -import {getWaypointIndex, hasReceipt, isFetchingWaypointsFromServer} from '@libs/TransactionUtils'; -import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; - -import type {TranslationPaths} from '@src/languages/types'; import type {Transaction} from '@src/types/onyx'; -import type {WaypointCollection} from '@src/types/onyx/Transaction'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; -import Icon from './Icon'; -import ImageSVG from './ImageSVG'; -import PendingMapView from './MapView/PendingMapView'; -import ReceiptImage from './ReceiptImage'; +import DistanceEReceiptPanel from './DistanceEReceiptPanel'; import ScrollView from './ScrollView'; -import Text from './Text'; type DistanceEReceiptProps = { /** The transaction for the distance expense */ @@ -34,86 +18,14 @@ type DistanceEReceiptProps = { function DistanceEReceipt({transaction, hoverPreview = false}: DistanceEReceiptProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {convertToDisplayString} = useCurrencyListActions(); - const icons = useMemoizedLazyExpensifyIcons(['ExpensifyWordmark']); - const thumbnail = hasReceipt(transaction) ? getThumbnailAndImageURIs(transaction).thumbnail : null; - const {amount: transactionAmount, currency: transactionCurrency, merchant: transactionMerchant, created: transactionDate} = getTransactionDetails(transaction) ?? {}; - const formattedTransactionAmount = convertToDisplayString(transactionAmount, transactionCurrency); - const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail ?? ''); - const waypoints = useMemo(() => transaction?.comment?.waypoints ?? {}, [transaction?.comment?.waypoints]); - const sortedWaypoints = useMemo( - () => - // The waypoint keys are sometimes out of order - Object.keys(waypoints) - .sort((keyA, keyB) => getWaypointIndex(keyA) - getWaypointIndex(keyB)) - .map((key) => ({[key]: waypoints[key]})) - .reduce((result, obj) => (obj ? Object.assign(result, obj) : result), {}), - [waypoints], - ); + return ( - - - - - {isFetchingWaypointsFromServer(transaction) || !thumbnailSource ? ( - - ) : ( - - )} - - - {transactionAmount !== null && transactionAmount !== undefined && {formattedTransactionAmount}} - {transactionMerchant !== translate('iou.fieldPending') ? transactionMerchant : transaction.merchant} - - - {Object.entries(sortedWaypoints).map(([key, waypoint]) => { - const index = getWaypointIndex(key); - let descriptionKey: TranslationPaths = 'distance.waypointDescription.stop'; - if (index === 0) { - descriptionKey = 'distance.waypointDescription.start'; - } - - return ( - - {translate(descriptionKey)} - {!!waypoint?.name && {waypoint.name}} - {!!waypoint?.address && {waypoint.address}} - - ); - })} - - {translate('common.date')} - {transactionDate} - - - - - - {translate('eReceipt.guaranteed')} - - + ); diff --git a/src/components/DistanceEReceiptPanel.tsx b/src/components/DistanceEReceiptPanel.tsx new file mode 100644 index 000000000000..53a8ef6ad82a --- /dev/null +++ b/src/components/DistanceEReceiptPanel.tsx @@ -0,0 +1,120 @@ +import EReceiptBackground from '@assets/images/eReceipt_background.svg'; + +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; +import {getTransactionDetails} from '@libs/ReportUtils'; +import {getWaypointIndex, hasPendingDistanceReceiptRegeneration, hasReceipt} from '@libs/TransactionUtils'; +import hasDistanceRouteErrors from '@libs/TransactionUtils/hasDistanceRouteErrors'; +import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; + +import type {TranslationPaths} from '@src/languages/types'; +import type {Transaction} from '@src/types/onyx'; +import type {WaypointCollection} from '@src/types/onyx/Transaction'; + +import React, {useMemo} from 'react'; +import {View} from 'react-native'; + +import Icon from './Icon'; +import ImageSVG from './ImageSVG'; +import PendingMapView from './MapView/PendingMapView'; +import ReceiptImage from './ReceiptImage'; +import Text from './Text'; + +type DistanceEReceiptPanelProps = { + /** The transaction for the distance expense */ + transaction: Transaction; +}; + +/** + * The distance e-receipt card on its own, at its natural height. `DistanceEReceipt` wraps it in a scrolling layout that + * fills its parent; `ScaledDistanceEReceipt` measures the card to scale it, so it renders this directly, because inside a + * parent with no height of its own the filling layout resolves to zero height on native. + */ +function DistanceEReceiptPanel({transaction}: DistanceEReceiptPanelProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {convertToDisplayString} = useCurrencyListActions(); + const icons = useMemoizedLazyExpensifyIcons(['ExpensifyWordmark']); + const thumbnail = hasReceipt(transaction) ? getThumbnailAndImageURIs(transaction).thumbnail : null; + const {amount: transactionAmount, currency: transactionCurrency, merchant: transactionMerchant, created: transactionDate} = getTransactionDetails(transaction) ?? {}; + const formattedTransactionAmount = convertToDisplayString(transactionAmount, transactionCurrency); + const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail ?? ''); + // The thumbnail this card draws is the stored route map. An edit that makes the server rebuild the receipt + // invalidates its URL, and a route error means there is no trip to draw, so show the pending map for both. + const canShowStoredRouteMap = !hasPendingDistanceReceiptRegeneration(transaction) && !hasDistanceRouteErrors(transaction); + const waypoints = useMemo(() => transaction?.comment?.waypoints ?? {}, [transaction?.comment?.waypoints]); + const sortedWaypoints = useMemo( + () => + // The waypoint keys are sometimes out of order + Object.keys(waypoints) + .sort((keyA, keyB) => getWaypointIndex(keyA) - getWaypointIndex(keyB)) + .map((key) => ({[key]: waypoints[key]})) + .reduce((result, obj) => (obj ? Object.assign(result, obj) : result), {}), + [waypoints], + ); + return ( + + + + + {!canShowStoredRouteMap || !thumbnailSource ? ( + + ) : ( + + )} + + + {transactionAmount !== null && transactionAmount !== undefined && {formattedTransactionAmount}} + {transactionMerchant !== translate('iou.fieldPending') ? transactionMerchant : transaction.merchant} + + + {Object.entries(sortedWaypoints).map(([key, waypoint]) => { + const index = getWaypointIndex(key); + let descriptionKey: TranslationPaths = 'distance.waypointDescription.stop'; + if (index === 0) { + descriptionKey = 'distance.waypointDescription.start'; + } + + return ( + + {translate(descriptionKey)} + {!!waypoint?.name && {waypoint.name}} + {!!waypoint?.address && {waypoint.address}} + + ); + })} + + {translate('common.date')} + {transactionDate} + + + + + + {translate('eReceipt.guaranteed')} + + + ); +} + +export default DistanceEReceiptPanel; diff --git a/src/components/TransactionItemRow/EditableCell/EditableCell.tsx b/src/components/EditableCell/EditableCell.tsx similarity index 100% rename from src/components/TransactionItemRow/EditableCell/EditableCell.tsx rename to src/components/EditableCell/EditableCell.tsx diff --git a/src/components/TransactionItemRow/EditableCell/EditingCellContext.tsx b/src/components/EditableCell/EditingCellContext.tsx similarity index 100% rename from src/components/TransactionItemRow/EditableCell/EditingCellContext.tsx rename to src/components/EditableCell/EditingCellContext.tsx diff --git a/src/components/EditableCell/InlineNumberEditCell.tsx b/src/components/EditableCell/InlineNumberEditCell.tsx new file mode 100644 index 000000000000..e93ffca75ce5 --- /dev/null +++ b/src/components/EditableCell/InlineNumberEditCell.tsx @@ -0,0 +1,132 @@ +/** + * Generic inline number editing cell for tables. Same role as InlineTextEditCell, but the + * editor is NumberWithSymbolForm so currency amounts and rates share focus, Escape, and save + * wiring instead of copying it per table. + * + * Callers convert to a frontend numeric string before passing `value`. Invalid values are + * handled by onSave, which no-ops on rejection. The cell then reverts to the original value. + */ +import NumberWithSymbolForm from '@components/NumberWithSymbolForm'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import TextWithTooltip from '@components/TextWithTooltip'; + +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; + +import CONST from '@src/CONST'; + +import React, {useRef} from 'react'; + +import type {EditableProps} from './types'; + +import EditableCell from './EditableCell'; +import useInlineEditState from './useInlineEditState'; + +type InlineNumberEditCellProps = { + /** Frontend numeric string used as the initial edit buffer. Conversion stays at the call site. */ + value: string; + + currency: string; + + /** Formatted display string (includes the currency symbol). Differs from `value`, which is the editable number only. */ + displayText: string; + + accessibilityLabel: string; + + /** NumberWithSymbolForm decimal places. Omit to use its default of 0. */ + decimals?: number; + + /** Lets the user type a leading minus. */ + allowNegativeInput?: boolean; + + /** + * Defaults to right, with the pencil on the left so it does not cover the number. + * Pass left to keep the pencil on the right. + */ + textAlign?: 'left' | 'right'; + + /** Defaults to Number() comparison so "1.00" and "1" are treated as unchanged. */ + isEqual?: (newValue: string, originalValue: string) => boolean; +} & EditableProps; + +const areNumericValuesEqual = (newValue: string, originalValue: string) => Number(newValue) === Number(originalValue); + +function InlineNumberEditCell({ + value, + currency, + displayText, + accessibilityLabel, + decimals, + allowNegativeInput = false, + textAlign = 'right', + isEqual = areNumericValuesEqual, + canEdit, + onSave, +}: InlineNumberEditCellProps) { + const styles = useThemeStyles(); + const {preferredLocale} = useLocalize(); + const inputRef = useRef(null); + const isRightAligned = textAlign === 'right'; + + const {isEditing, setLocalValue, startEditing, save, cancelEditing} = useInlineEditState(canEdit, value, onSave, isEqual); + + const focusOnMount = (ref: BaseTextInputRef | null) => { + inputRef.current = ref; + ref?.focus(); + }; + + const handleEscape = () => { + cancelEditing(); + inputRef.current?.blur(); + }; + + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, handleEscape, {captureOnInputs: true, isActive: isEditing}); + + return ( + + } + > + + + ); +} + +export default InlineNumberEditCell; diff --git a/src/components/EditableCell/InlineTextEditCell.tsx b/src/components/EditableCell/InlineTextEditCell.tsx new file mode 100644 index 000000000000..83a16dc5c69e --- /dev/null +++ b/src/components/EditableCell/InlineTextEditCell.tsx @@ -0,0 +1,100 @@ +import TextInput from '@components/TextInput'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import TextWithTooltip from '@components/TextWithTooltip'; + +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import StringUtils from '@libs/StringUtils'; + +import CONST from '@src/CONST'; + +import type {StyleProp, TextStyle} from 'react-native'; + +import React, {useRef} from 'react'; + +import type {EditableProps} from './types'; + +import EditableCell from './EditableCell'; +import useInlineEditState from './useInlineEditState'; + +type InlineTextEditCellProps = { + /** Current value shown in display mode and used as the initial edit buffer */ + value: string; + + accessibilityLabel: string; + + /** Style applied to the display text and to the TextInput while editing, so font size and color stay in sync */ + displayTextStyle?: StyleProp; + + /** + * Normalizes the value before saving and for change detection. + * Defaults to sanitizeName so a pasted non-breaking space is treated as unchanged when the action would no-op. + */ + sanitize?: (value: string) => string; +} & EditableProps; + +/** + * Generic inline text editing cell for tables. Composes EditableCell with an inline TextInput + * and the shared inline edit state. Domain tables supply the value, a canEdit flag, and an onSave + * handler that performs the actual persistence. + * + * Invalid values are handled by onSave, which no-ops on rejection. The cell then reverts to the + * original value. On narrow layouts EditableCell renders the display text only, so tables keep + * their existing tap-to-navigate behavior there. + */ +function InlineTextEditCell({value, accessibilityLabel, displayTextStyle, canEdit, onSave, sanitize = StringUtils.sanitizeName}: InlineTextEditCellProps) { + const styles = useThemeStyles(); + const inputRef = useRef(null); + + const {isEditing, localValue, setLocalValue, startEditing, save, cancelEditing} = useInlineEditState( + canEdit, + value, + onSave ? (newValue) => onSave(sanitize(newValue)) : undefined, + (newValue, originalValue) => sanitize(newValue) === sanitize(originalValue), + ); + + const handleChangeText = (text: string) => setLocalValue(StringUtils.lineBreaksToSpaces(text)); + + const handleEscape = () => { + cancelEditing(); + inputRef.current?.blur(); + }; + + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, handleEscape, {captureOnInputs: true, isActive: isEditing}); + + return ( + + } + > + + + ); +} + +export default InlineTextEditCell; diff --git a/src/components/TransactionItemRow/EditableCell/index.ts b/src/components/EditableCell/index.ts similarity index 72% rename from src/components/TransactionItemRow/EditableCell/index.ts rename to src/components/EditableCell/index.ts index 97412b9e7e7a..979840643a2c 100644 --- a/src/components/TransactionItemRow/EditableCell/index.ts +++ b/src/components/EditableCell/index.ts @@ -1,4 +1,6 @@ export {default as EditableCell} from './EditableCell'; +export {default as InlineNumberEditCell} from './InlineNumberEditCell'; +export {default as InlineTextEditCell} from './InlineTextEditCell'; export {default as EditingCellProvider} from './EditingCellContext'; export {useEditingCellState} from './EditingCellContext'; export {default as useInlineEditState} from './useInlineEditState'; diff --git a/src/components/TransactionItemRow/EditableCell/types.ts b/src/components/EditableCell/types.ts similarity index 100% rename from src/components/TransactionItemRow/EditableCell/types.ts rename to src/components/EditableCell/types.ts diff --git a/src/components/TransactionItemRow/EditableCell/useInlineEditState.ts b/src/components/EditableCell/useInlineEditState.ts similarity index 100% rename from src/components/TransactionItemRow/EditableCell/useInlineEditState.ts rename to src/components/EditableCell/useInlineEditState.ts diff --git a/src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts b/src/components/EditableCell/usePopoverEditState.ts similarity index 100% rename from src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts rename to src/components/EditableCell/usePopoverEditState.ts diff --git a/src/components/ErrorBoundary/logError.ts b/src/components/ErrorBoundary/logError.ts index 4e9074761f0b..6e1da7795ff6 100644 --- a/src/components/ErrorBoundary/logError.ts +++ b/src/components/ErrorBoundary/logError.ts @@ -1,13 +1,27 @@ import Log from '@libs/Log'; +import CONST from '@src/CONST'; + import * as Sentry from '@sentry/react-native'; import type {LogError} from './types'; const logError: LogError = (errorMessage, error, errorInfo) => { Log.alert(`${errorMessage} - ${error.message}`, {errorInfo}, false); + if (error.message === CONST.ERROR.UPDATE_REQUIRED) { + return; + } + Sentry.addBreadcrumb({message: `errorInfo: ${errorInfo}`}); - Sentry.captureException(error, {extra: {errorInfo}}); + // The root boundary replaces the app with a blocking fallback, so catching the error does not mean the app recovered. + Sentry.captureException(error, { + mechanism: {type: 'generic', handled: false}, + captureContext: { + level: 'fatal', + extra: {errorInfo}, + tags: {[CONST.TELEMETRY.TAGS.APP_BLOCKING]: 'true'}, + }, + }); }; export default logError; diff --git a/src/components/ExceededCommentLength.tsx b/src/components/ExceededCommentLength.tsx index 368cc6c992b5..598b34ecb493 100644 --- a/src/components/ExceededCommentLength.tsx +++ b/src/components/ExceededCommentLength.tsx @@ -1,4 +1,5 @@ import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -14,13 +15,14 @@ type ExceededCommentLengthProps = { function ExceededCommentLength({maxCommentLength = CONST.MAX_COMMENT_LENGTH, isTaskTitle = false}: ExceededCommentLengthProps) { const styles = useThemeStyles(); + const theme = useTheme(); const {numberFormat, translate} = useLocalize(); const translationKey = isTaskTitle ? 'composer.taskTitleExceededMaxLength' : 'composer.commentExceededMaxLength'; return ( {translate(translationKey, numberFormat(maxCommentLength))} diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx new file mode 100644 index 000000000000..555aa6c9f50e --- /dev/null +++ b/src/components/ExpenseAddedGrowl.tsx @@ -0,0 +1,175 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; +import useReportTransactions from '@hooks/useReportTransactions'; + +import {close} from '@libs/actions/Modal'; +import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; +import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction'; +import Log from '@libs/Log'; +import navigateToCreatedExpense from '@libs/Navigation/helpers/navigateToCreatedExpense'; +import Navigation from '@libs/Navigation/Navigation'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import {findSelfDMReportID, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; +import {isTransactionPendingDelete} from '@libs/TransactionUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; + +import type {Dispatch, SetStateAction} from 'react'; + +import {useEffect, useRef, useState} from 'react'; + +import GrowlNotificationContent from './GrowlNotification/GrowlNotificationContent'; + +type ActiveGrowl = { + /** ID of the transaction the growl was raised for */ + transactionID: string; + + /** Search data type the growl belongs to (expense, invoice, etc.) */ + dataType: SearchDataTypes; + + /** Identifies each growl instance so a stale slide-out dismissal can't clear a newer growl */ + nonce: number; +}; + +type ExpenseAddedGrowlContentProps = { + /** Transaction to read for the growl: the active growl's transaction, or the latest pending signal ID */ + transactionID: string; + + /** Pending expense-added notifications, keyed by transaction ID with its search data type as the value */ + signal: Record | undefined; + + /** Currently displayed growl */ + active: ActiveGrowl | null; + + /** Setter for the active growl */ + setActive: Dispatch>; +}; + +/** Watches the "an expense was just added" Onyx signal and shows an "Expense added" growl with a "View" action. */ +function ExpenseAddedGrowl() { + const [active, setActive] = useState(null); + const [signal] = useOnyx(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + const transactionID = active?.transactionID ?? Object.keys(signal ?? {}).at(-1); + + if (!transactionID) { + return null; + } + + return ( + + ); +} + +function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: ExpenseAddedGrowlContentProps) { + const nonceRef = useRef(0); + + const {translate} = useLocalize(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const reportID = transaction?.reportID; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + // A tracked expense's IOU action lives on the self-DM report, not on transaction.reportID, so read actions + // from the report that actually hosts it so "View" can resolve the real transaction thread. + const isUnreportedExpense = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const selfDMReportID = isUnreportedExpense ? findSelfDMReportID() : undefined; + const hostReportID = isUnreportedExpense ? selfDMReportID : reportID; + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`); + const iouAction = getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID); + const [personalDetails] = usePersonalDetailsByIDs([currentUserPersonalDetails?.accountID, iouAction?.actorAccountID]); + + // A tracked expense lives in a chat, not an expense report. Leaving iouReportID undefined tells + // navigateToCreatedExpense to open the transaction thread directly instead of an expense report RHP. + const iouReport = isMoneyRequestReport(report) || isInvoiceReport(report) ? report : undefined; + const iouReportID = iouReport?.reportID; + const reportTransactions = useReportTransactions(iouReportID); + + useEffect(() => { + if (active) { + return; + } + const pendingTransactionIDs = Object.keys(signal ?? {}); + + // The last key is the last-created transaction: IDs are large (rand64), so they exceed the array-index + // range and JS preserves insertion order rather than sorting them numerically ascending. + const latestTransactionID = pendingTransactionIDs.at(-1); + const dataType = latestTransactionID ? signal?.[latestTransactionID] : undefined; + if (!latestTransactionID || !dataType || !transaction) { + return; + } + + // Clear every pending ID but surface only the latest: rapid creations that pile up before this runs + // collapse into a single growl for the newest expense. + mergeExpenseAddedGrowlTransactionIDs(Object.fromEntries(pendingTransactionIDs.map((id) => [id, null]))); + + // Suppress the growl when the user is already viewing the expense's money-request report + if (Navigation.getFocusedReportId() === transaction.reportID) { + return; + } + nonceRef.current += 1; + setActive({transactionID: latestTransactionID, dataType, nonce: nonceRef.current}); + }, [signal, active, transaction, setActive]); + + if (!active) { + return null; + } + + const isInvoice = active.dataType === CONST.SEARCH.DATA_TYPES.INVOICE; + + // Build the thread on press rather than when the growl shows, so it is only created if the user taps + // "View" and is built against the freshest Onyx data. + const navigateToExpense = () => { + // The expense was deleted while the growl was up, so there is nothing left to open. + if (!transaction || isTransactionPendingDelete(transaction)) { + return; + } + let threadReportID = transaction?.transactionThreadReportID ?? iouAction?.childReportID; + if (threadReportID) { + setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); + } else { + const optimisticThread = createTransactionThreadReport({ + introSelected, + conciergeChat, + currentUserLogin: currentUserPersonalDetails?.login ?? '', + currentUserAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, + iouReport, + iouReportAction: iouAction, + transaction, + personalDetails, + }); + threadReportID = optimisticThread?.reportID; + } + if (!threadReportID) { + Log.warn('[ExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.'); + return; + } + // The growl sits above popovers, so close any open one first, the same as tapping a notification. + close(() => navigateToCreatedExpense({threadReportID, transactionID: active.transactionID, iouReportID, reportTransactions})); + }; + + return ( + setActive((prev) => (prev?.nonce === dismissedNonce ? null : prev))} + /> + ); +} + +export default ExpenseAddedGrowl; diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx index fb977c60a232..fc35d965553e 100644 --- a/src/components/FlatList/FlatList/index.tsx +++ b/src/components/FlatList/FlatList/index.tsx @@ -5,11 +5,11 @@ import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; import useThemeStyles from '@hooks/useThemeStyles'; import {isMobileSafari} from '@libs/Browser'; +import mergeRefs from '@libs/mergeRefs'; -import type {ForwardedRef, RefObject} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useLayoutEffect, useRef} from 'react'; import {FlatList} from 'react-native'; import type {CustomFlatListProps} from './types'; @@ -18,35 +18,9 @@ import type {CustomFlatListProps} from './types'; // We do a best effort to avoid content jumping by using some hacks on mobile Safari only. const IS_MOBILE_SAFARI = isMobileSafari(); -function mergeRefs(...args: Array | ForwardedRef | null>) { - return function (node: FlatList) { - for (const ref of args) { - if (ref == null) { - continue; - } - if (typeof ref === 'function') { - ref(node); - continue; - } - if (typeof ref === 'object') { - ref.current = node; - continue; - } - console.error(`mergeRefs cannot handle Refs of type boolean, number or string, received ref ${String(ref)}`); - } - }; -} - -function useMergeRefs(...args: Array | ForwardedRef | null>) { - return useMemo( - () => mergeRefs(...args), - // eslint-disable-next-line - [...args], - ); -} - function getScrollableNode(flatList: FlatList | null): HTMLElement | undefined { - return flatList?.getScrollableNode() as HTMLElement | undefined; + const scrollableNode: unknown = flatList?.getScrollableNode(); + return scrollableNode instanceof HTMLElement ? scrollableNode : undefined; } function MVCPFlatList({ @@ -67,7 +41,10 @@ function MVCPFlatList({ const lastScrollOffsetRef = useRef(0); const isListRenderedRef = useRef(false); const mvcpAutoscrollToTopThresholdRef = useRef(mvcpAutoscrollToTopThreshold); - mvcpAutoscrollToTopThresholdRef.current = mvcpAutoscrollToTopThreshold; + + useLayoutEffect(() => { + mvcpAutoscrollToTopThresholdRef.current = mvcpAutoscrollToTopThreshold; + }, [mvcpAutoscrollToTopThreshold]); const getScrollOffset = useCallback((): number => { if (!listRef.current) { @@ -115,7 +92,10 @@ function MVCPFlatList({ const contentViewLength = contentView.childNodes.length; for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) { - const subview = contentView.childNodes[i] as HTMLElement; + const subview = contentView.childNodes[i]; + if (!(subview instanceof HTMLElement)) { + continue; + } const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop; if (subviewOffset > scrollOffset) { prevFirstVisibleOffsetRef.current = subviewOffset; @@ -171,7 +151,7 @@ function MVCPFlatList({ firstVisibleViewRef.current = null; } for (const node of mutation.addedNodes) { - if (node.nodeType !== Node.ELEMENT_NODE || !(node as HTMLElement).querySelector('#composer')) { + if (!(node instanceof Element) || !node.querySelector('#composer')) { continue; } isEditComposerAdded = true; @@ -213,8 +193,6 @@ function MVCPFlatList({ }; }, [prepareForMaintainVisibleContentPosition, setupMutationObserver]); - const setMergedRef = useMergeRefs(listRef, ref as ForwardedRef); - const onRef = useCallback( (newRef: FlatList) => { // Make sure to only call refs and re-attach listeners if the node changed. @@ -222,11 +200,11 @@ function MVCPFlatList({ return; } - setMergedRef(newRef); + mergeRefs(listRef, ref)(newRef); prepareForMaintainVisibleContentPosition(); setupMutationObserver(); }, - [prepareForMaintainVisibleContentPosition, setMergedRef, setupMutationObserver], + [prepareForMaintainVisibleContentPosition, ref, setupMutationObserver], ); useFlatListHandle({ diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 2a5df318e2f6..853d78f54398 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -23,6 +23,7 @@ import type TimeModalPicker from '@components/TimeModalPicker'; import type UploadFile from '@components/UploadFile'; import type ValuePicker from '@components/ValuePicker'; +import type {AccessibilityFocusTarget} from '@libs/Accessibility/moveAccessibilityFocus/types'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type ConstantSelector from '@pages/Debug/ConstantSelector'; @@ -44,8 +45,8 @@ import type {BaseForm} from '@src/types/form/Form'; import type {FileObject} from '@src/types/utils/Attachment'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import type {ComponentRef, ComponentType, FocusEvent, Key, ReactNode, Ref, RefObject} from 'react'; -import type {GestureResponderEvent, HostComponent, InputModeOptions, KeyboardTypeOptions, StyleProp, SubmitBehavior, TextInputSubmitEditingEvent, ViewStyle} from 'react-native'; +import type {ComponentType, FocusEvent, Key, ReactNode, Ref, RefObject} from 'react'; +import type {GestureResponderEvent, InputModeOptions, KeyboardTypeOptions, StyleProp, SubmitBehavior, TextInputSubmitEditingEvent, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; /** @@ -136,7 +137,7 @@ type InputComponentBaseProps = Input uncontrolled?: boolean; inputMode?: InputModeOptions; keyboardType?: KeyboardTypeOptions; - getNativeRef?: () => ComponentRef> & RefObject; + getNativeRef?: () => AccessibilityFocusTarget; }; type FormOnyxValues = Omit; diff --git a/src/components/GPSInProgressModal/index.native.tsx b/src/components/GPSInProgressModal/index.native.tsx deleted file mode 100644 index 7df4b41635c0..000000000000 --- a/src/components/GPSInProgressModal/index.native.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import ConfirmModal from '@components/ConfirmModal'; - -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; - -import {closeReactNativeApp} from '@libs/actions/HybridApp'; -import {setIsGPSInProgressModalOpen} from '@libs/actions/isGPSInProgressModalOpen'; -import {getGpsPoints, stopGpsTrip} from '@libs/GPSDraftDetailsUtils'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -function GPSInProgressModal() { - const [isGPSInProgressModalOpen] = useOnyx(ONYXKEYS.IS_GPS_IN_PROGRESS_MODAL_OPEN); - const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); - const {translate} = useLocalize(); - const {isOffline} = useNetwork(); - - const stopGpsAndSwitchToOD = async () => { - setIsGPSInProgressModalOpen(false); - await stopGpsTrip(isOffline, getGpsPoints(gpsDraftDetails)); - closeReactNativeApp({shouldSetNVP: true, isTrackingGPS: false, shouldIgnoreTryNewDotLoading: true}); - }; - - return ( - setIsGPSInProgressModalOpen(false)} - // eslint-disable-next-line @typescript-eslint/no-misused-promises - onConfirm={stopGpsAndSwitchToOD} - confirmText={translate('gps.switchToODWarningTripInProgress.confirm')} - cancelText={translate('common.cancel')} - prompt={translate('gps.switchToODWarningTripInProgress.prompt')} - buttonVariant={CONST.BUTTON_VARIANT.DANGER} - /> - ); -} - -GPSInProgressModal.displayName = 'GPSInProgressModal'; - -export default GPSInProgressModal; diff --git a/src/components/GPSInProgressModal/index.tsx b/src/components/GPSInProgressModal/index.tsx deleted file mode 100644 index 5975cb3aea35..000000000000 --- a/src/components/GPSInProgressModal/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -// GPS is not available on web -function GPSInProgressModal() { - return null; -} - -GPSInProgressModal.displayName = 'GPSInProgressModal'; - -export default GPSInProgressModal; diff --git a/src/components/GPSTripStateChecker/index.tsx b/src/components/GPSTripStateChecker/index.tsx deleted file mode 100644 index ecf6bf5420ad..000000000000 --- a/src/components/GPSTripStateChecker/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -function GPSTripStateChecker() { - return null; -} - -GPSTripStateChecker.displayName = 'GPSTripStateChecker'; - -export default GPSTripStateChecker; diff --git a/src/components/GpsDraftDetailsRefSync.tsx b/src/components/GpsDraftDetailsRefSync.tsx new file mode 100644 index 000000000000..f24d1c68cfd8 --- /dev/null +++ b/src/components/GpsDraftDetailsRefSync.tsx @@ -0,0 +1,29 @@ +import useOnyx from '@hooks/useOnyx'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; + +import type {RefObject} from 'react'; + +import {useEffect} from 'react'; + +type GpsDraftDetailsRefSyncProps = { + gpsDraftDetailsRef: RefObject; +}; + +/** + * Keeps the latest GPS draft details in a ref without re-rendering the parent. + * Mount only while copilot leave + active GPS trip (e.g. require-2FA overlay). + */ +function GpsDraftDetailsRefSync({gpsDraftDetailsRef}: GpsDraftDetailsRefSyncProps) { + const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); + + useEffect(() => { + // eslint-disable-next-line no-param-reassign -- ref sync intentionally mutates .current for the parent overlay + gpsDraftDetailsRef.current = gpsDraftDetails; + }, [gpsDraftDetails, gpsDraftDetailsRef]); + + return null; +} + +export default GpsDraftDetailsRefSync; diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx index 89951c9af8bb..bbc7ab9907c6 100644 --- a/src/components/GrowlNotification/GrowlNotificationContent.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx @@ -22,6 +22,7 @@ import {useSharedValue, withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; import GrowlNotificationContainer from './GrowlNotificationContainer'; +import GrowlNotificationPortal from './GrowlNotificationPortal'; const INACTIVE_OFFSET = CONST.GROWL.OFFSCREEN_OFFSET; const INACTIVE_POSITION_Y = -INACTIVE_OFFSET; @@ -130,51 +131,53 @@ function GrowlNotificationContent({bodyText, type, duration, action, nonce, onDi .onStart(triggerDismiss); return ( - - - - - {/* The dismiss target covers only the icon + text; the action Button is a sibling - (not nested inside a pressable) so it stays independently focusable for screen - readers and its press can't bubble into a dismiss. */} - - - {bodyText} - - {!!action && ( - - )} - - - - + + {bodyText} + + {!!action && ( + + )} + + + + + ); } diff --git a/src/components/GrowlNotification/GrowlNotificationPortal/index.native.tsx b/src/components/GrowlNotification/GrowlNotificationPortal/index.native.tsx new file mode 100644 index 000000000000..1878c9c379c1 --- /dev/null +++ b/src/components/GrowlNotification/GrowlNotificationPortal/index.native.tsx @@ -0,0 +1,8 @@ +import type React from 'react'; + +/** Native has no shared DOM stacking context to escape, so growls render where they are mounted. */ +function GrowlNotificationPortal({children}: React.PropsWithChildren) { + return children; +} + +export default GrowlNotificationPortal; diff --git a/src/components/GrowlNotification/GrowlNotificationPortal/index.tsx b/src/components/GrowlNotification/GrowlNotificationPortal/index.tsx new file mode 100644 index 000000000000..535fb208d6d4 --- /dev/null +++ b/src/components/GrowlNotification/GrowlNotificationPortal/index.tsx @@ -0,0 +1,13 @@ +import type React from 'react'; + +import {createPortal} from 'react-dom'; + +/** + * Renders growls at the document root. Inside the app's own stacking context they are covered by overlays that + * portal to the body themselves, such as the Side Panel, no matter how high their z-index is. + */ +function GrowlNotificationPortal({children}: React.PropsWithChildren) { + return createPortal(children, document.body); +} + +export default GrowlNotificationPortal; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx index e10288e35496..c404b6ac6a0c 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx @@ -4,6 +4,7 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useEnterKeyHandler from '@hooks/useEnterKeyHandler'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; import {navigateToConciergeChat as navigateToConciergeChatAction} from '@userActions/Report'; @@ -35,6 +36,7 @@ function ConciergeLinkRenderer({tnode, style}: ConciergeLinkRendererProps) { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {onPress: onPressFromProps} = useRendererProps('concierge-link') ?? {}; @@ -43,7 +45,7 @@ function ConciergeLinkRenderer({tnode, style}: ConciergeLinkRendererProps) { */ const navigateToConciergeChat = () => { onPressFromProps?.(); - navigateToConciergeChatAction({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: false}); + navigateToConciergeChatAction({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); }; // Define link style based on context diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index 04dc6f955f11..a104930fc5a0 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -1,4 +1,4 @@ -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Header from '@components/Header'; import Modal from '@components/Modal'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -74,14 +74,11 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr {/* GestureHandlerRootView is required for gestures inside an Android modal (separate native window), and painting appBG here avoids the unpainted modal base flashing through on dark themes */} - +
+ {shouldUseNarrowLayout && } + + {!shouldUseNarrowLayout && } +
` (content lives in the child blocks). */ +type HeaderProps = { + /** The composed content of the header: primitive blocks and the `Header.Right` zone. */ + children: ReactNode; + + shouldSkipFocusAfterTransition?: boolean; + + /** Additional styles to add to the outer header bar. */ + style?: StyleProp; +}; + +/** Composed counterpart of `HeaderWithBackButton`. Content is composed from primitive blocks and the `Header.Right` zone (see the barrel for a full example). */ +function Header({children, shouldSkipFocusAfterTransition = false, style}: HeaderProps) { + const isInLandscapeMode = useIsInLandscapeMode(); + const {contentHeaderHeightStyle} = useContentHeaderHeight(); + const styles = useThemeStyles(); + + return ( + Keyboard.dismiss() : undefined} + > + + + {children} + + + + ); +} + +export default Header; diff --git a/src/components/Header/composed/HeaderWithBackButtonAndTitle.tsx b/src/components/Header/composed/HeaderWithBackButtonAndTitle.tsx new file mode 100644 index 000000000000..fbbe86365962 --- /dev/null +++ b/src/components/Header/composed/HeaderWithBackButtonAndTitle.tsx @@ -0,0 +1,36 @@ +/** + * Narrow preset over `
` for the common back-button-plus-title case, so call-sites that need + * nothing else don't have to compose the primitives by hand. + */ +import Header from '@components/Header/Header'; +import HeaderRight from '@components/Header/layout/HeaderRight'; +import HeaderBackButton from '@components/Header/primitives/HeaderBackButton'; +import HeaderTitle from '@components/Header/primitives/HeaderTitle'; + +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; + +type HeaderWithBackButtonAndTitleProps = Partial & { + title?: string; + subtitle?: string; + style?: StyleProp; + titleStyles?: StyleProp; + onBackButtonPress?: () => void; +}; + +function HeaderWithBackButtonAndTitle({children, onBackButtonPress, title = '', subtitle = '', titleStyles, style}: HeaderWithBackButtonAndTitleProps) { + return ( +
+ + + {children} +
+ ); +} + +export default HeaderWithBackButtonAndTitle; diff --git a/src/components/Header/context/HeaderContext.tsx b/src/components/Header/context/HeaderContext.tsx new file mode 100644 index 000000000000..be8334ee933a --- /dev/null +++ b/src/components/Header/context/HeaderContext.tsx @@ -0,0 +1,14 @@ +import {createContext, useContext} from 'react'; + +type HeaderContextValue = { + shouldSkipFocusAfterTransition: boolean; +}; + +const HeaderContext = createContext({shouldSkipFocusAfterTransition: false}); + +function useHeaderContext(): HeaderContextValue { + return useContext(HeaderContext); +} + +export default HeaderContext; +export {useHeaderContext}; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 000000000000..75db3214df85 --- /dev/null +++ b/src/components/Header/index.tsx @@ -0,0 +1,73 @@ +/** + * Header – composed counterpart of the legacy `HeaderWithBackButton`. + * + * Blocks are composed as children, left to right in this order: + * - `Header.BackButton` (optional) + * - `Header.Icon` (optional) — mutually exclusive with `Header.AvatarWithDisplayName` + * - `Header.Title` or `Header.AvatarWithDisplayName` — pick one, not both + * - `Header.Right`, wrapping the trailing zone: + * - `Header.Actions` for custom children and/or `Header.DownloadButton` + * - `Header.ThreeDotsMenu` (or a single `Header.IconButton` for a one-off action) + * - `Header.CloseButton` + * + * @example + * ```tsx + * import Header from '@components/Header'; + * + *
+ * + * + * + * + * + * + * + * + * + * + * + *
+ * ``` + * + * Report-header variant, swapping `Header.Title` for `Header.AvatarWithDisplayName` and + * `Header.ThreeDotsMenu` for a single one-off `Header.IconButton`: + * ```tsx + *
+ * + * + * + * + * + *
+ * ``` + */ +import HeaderComponent from './Header'; +import HeaderActions from './layout/HeaderActions'; +import HeaderRight from './layout/HeaderRight'; +import HeaderAvatarWithDisplayName from './primitives/HeaderAvatarWithDisplayName'; +import HeaderBackButton from './primitives/HeaderBackButton'; +import HeaderCloseButton from './primitives/HeaderCloseButton'; +import HeaderDownloadButton from './primitives/HeaderDownloadButton'; +import HeaderIcon from './primitives/HeaderIcon'; +import HeaderIconButton from './primitives/HeaderIconButton'; +import HeaderThreeDotsMenu from './primitives/HeaderThreeDotsMenu'; +import HeaderTitle from './primitives/HeaderTitle'; + +function HeaderBase(props: React.ComponentProps) { + return ; +} + +const Header = Object.assign(HeaderBase, { + Actions: HeaderActions, + Right: HeaderRight, + AvatarWithDisplayName: HeaderAvatarWithDisplayName, + BackButton: HeaderBackButton, + CloseButton: HeaderCloseButton, + DownloadButton: HeaderDownloadButton, + Icon: HeaderIcon, + IconButton: HeaderIconButton, + ThreeDotsMenu: HeaderThreeDotsMenu, + Title: HeaderTitle, +}); + +export default Header; diff --git a/src/components/HeaderWithBackButtonComposed/layout/HeaderActions.tsx b/src/components/Header/layout/HeaderActions.tsx similarity index 100% rename from src/components/HeaderWithBackButtonComposed/layout/HeaderActions.tsx rename to src/components/Header/layout/HeaderActions.tsx diff --git a/src/components/HeaderWithBackButtonComposed/layout/HeaderRight.tsx b/src/components/Header/layout/HeaderRight.tsx similarity index 100% rename from src/components/HeaderWithBackButtonComposed/layout/HeaderRight.tsx rename to src/components/Header/layout/HeaderRight.tsx diff --git a/src/components/Header/primitives/HeaderAvatarWithDisplayName.tsx b/src/components/Header/primitives/HeaderAvatarWithDisplayName.tsx new file mode 100644 index 000000000000..5220c0b87400 --- /dev/null +++ b/src/components/Header/primitives/HeaderAvatarWithDisplayName.tsx @@ -0,0 +1,12 @@ +import type {AvatarWithDisplayNameProps} from '@components/AvatarWithDisplayName'; +import AvatarWithDisplayName from '@components/AvatarWithDisplayName'; + +import useDialogLabelRegistration from '@hooks/useDialogLabelRegistration'; + +function HeaderAvatarWithDisplayName(props: AvatarWithDisplayNameProps) { + useDialogLabelRegistration(props.report?.reportName ?? ''); + + return ; +} + +export default HeaderAvatarWithDisplayName; diff --git a/src/components/HeaderWithBackButtonComposed/primitives/HeaderBackButton.tsx b/src/components/Header/primitives/HeaderBackButton.tsx similarity index 84% rename from src/components/HeaderWithBackButtonComposed/primitives/HeaderBackButton.tsx rename to src/components/Header/primitives/HeaderBackButton.tsx index ea1a722d1c8c..0ebe7b3fc654 100644 --- a/src/components/HeaderWithBackButtonComposed/primitives/HeaderBackButton.tsx +++ b/src/components/Header/primitives/HeaderBackButton.tsx @@ -1,3 +1,5 @@ +import {useHeaderContext} from '@components/Header/context/HeaderContext'; + import useInitialFocusRef from '@hooks/useInitialFocusRef'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -17,15 +19,13 @@ type HeaderBackButtonProps = { /** Optional fill color for the icon. */ iconFill?: string; - - /** Whether to skip focus of the back button after the RHP transition (screen reader). */ - shouldSkipFocusAfterTransition?: boolean; }; -function HeaderBackButton({onPress = () => Navigation.goBack(), iconFill, shouldSkipFocusAfterTransition = false}: HeaderBackButtonProps) { +function HeaderBackButton({onPress = () => Navigation.goBack(), iconFill}: HeaderBackButtonProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['BackArrow']); + const {shouldSkipFocusAfterTransition} = useHeaderContext(); const setBackButtonRef = useInitialFocusRef({shouldSkip: shouldSkipFocusAfterTransition}); return ( @@ -40,7 +40,7 @@ function HeaderBackButton({onPress = () => Navigation.goBack(), iconFill, should }} iconSrc={icons.BackArrow} iconFill={iconFill} - // Header makes the space for this button with the padding, so we need to add negative margin to fill the space + // Header makes the space for this button with the padding, so we need to add negative margin to fill the space - instead of reducing the padding from pl5 to pl2 style={styles.mln3} id={CONST.BACK_BUTTON_NATIVE_ID} sentryLabel={CONST.SENTRY_LABEL.HEADER.BACK_BUTTON} diff --git a/src/components/HeaderWithBackButtonComposed/primitives/HeaderCloseButton.tsx b/src/components/Header/primitives/HeaderCloseButton.tsx similarity index 81% rename from src/components/HeaderWithBackButtonComposed/primitives/HeaderCloseButton.tsx rename to src/components/Header/primitives/HeaderCloseButton.tsx index 4c7eeb25b9a1..133abcce2725 100644 --- a/src/components/HeaderWithBackButtonComposed/primitives/HeaderCloseButton.tsx +++ b/src/components/Header/primitives/HeaderCloseButton.tsx @@ -1,13 +1,15 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import Navigation from '@libs/Navigation/Navigation'; + import CONST from '@src/CONST'; import HeaderIconButton from './HeaderIconButton'; type HeaderCloseButtonProps = { /** Method to trigger when pressing the close button of the header. */ - onPress: () => void; + onPress?: () => void; /** The fill color for the close icon. */ iconFill?: string; @@ -16,7 +18,7 @@ type HeaderCloseButtonProps = { /** * A tooltip-wrapped close icon button. */ -function HeaderCloseButton({onPress, iconFill}: HeaderCloseButtonProps) { +function HeaderCloseButton({onPress = () => Navigation.dismissModal(), iconFill}: HeaderCloseButtonProps) { const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Close']); diff --git a/src/components/HeaderWithBackButtonComposed/primitives/HeaderDownloadButton.tsx b/src/components/Header/primitives/HeaderDownloadButton.tsx similarity index 100% rename from src/components/HeaderWithBackButtonComposed/primitives/HeaderDownloadButton.tsx rename to src/components/Header/primitives/HeaderDownloadButton.tsx diff --git a/src/components/HeaderWithBackButtonComposed/primitives/HeaderIcon.tsx b/src/components/Header/primitives/HeaderIcon.tsx similarity index 100% rename from src/components/HeaderWithBackButtonComposed/primitives/HeaderIcon.tsx rename to src/components/Header/primitives/HeaderIcon.tsx diff --git a/src/components/HeaderWithBackButtonComposed/primitives/HeaderIconButton.tsx b/src/components/Header/primitives/HeaderIconButton.tsx similarity index 100% rename from src/components/HeaderWithBackButtonComposed/primitives/HeaderIconButton.tsx rename to src/components/Header/primitives/HeaderIconButton.tsx diff --git a/src/components/HeaderWithBackButtonComposed/primitives/HeaderThreeDotsMenu.tsx b/src/components/Header/primitives/HeaderThreeDotsMenu.tsx similarity index 97% rename from src/components/HeaderWithBackButtonComposed/primitives/HeaderThreeDotsMenu.tsx rename to src/components/Header/primitives/HeaderThreeDotsMenu.tsx index a3d82e53c119..7d8bfdfa5d1c 100644 --- a/src/components/HeaderWithBackButtonComposed/primitives/HeaderThreeDotsMenu.tsx +++ b/src/components/Header/primitives/HeaderThreeDotsMenu.tsx @@ -42,4 +42,3 @@ function HeaderThreeDotsMenu({items, onIconPress = () => {}, shouldOverlay = fal } export default HeaderThreeDotsMenu; -export {DEFAULT_ANCHOR_ALIGNMENT}; diff --git a/src/components/Header/primitives/HeaderTitle.tsx b/src/components/Header/primitives/HeaderTitle.tsx new file mode 100644 index 000000000000..0a5510d08f2b --- /dev/null +++ b/src/components/Header/primitives/HeaderTitle.tsx @@ -0,0 +1,35 @@ +import {useHeaderContext} from '@components/Header/context/HeaderContext'; +import HeaderTitleComponent from '@components/HeaderTitle'; + +import type {StyleProp, TextStyle} from 'react-native'; + +type HeaderTitleProps = { + title?: string; + subtitle?: string; + subtitleLink?: string; + titleStyles?: StyleProp; +}; + +function HeaderTitle({title, subtitle, titleStyles, subtitleLink}: HeaderTitleProps) { + const {shouldSkipFocusAfterTransition} = useHeaderContext(); + + return ( + + {!!title && ( + + {title} + + )} + {!!subtitle && {subtitle}} + {!!subtitleLink && {subtitleLink}} + + ); +} + +export default HeaderTitle; diff --git a/src/components/HeaderWithBackButtonComposed/HeaderWithBackButton.tsx b/src/components/HeaderWithBackButtonComposed/HeaderWithBackButton.tsx deleted file mode 100644 index 6f7504e307c6..000000000000 --- a/src/components/HeaderWithBackButtonComposed/HeaderWithBackButton.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import AvatarFromIcon from '@components/Avatar/AvatarFromIcon'; -import AvatarWithDisplayName from '@components/AvatarWithDisplayName'; -import type HeaderWithBackButtonProps from '@components/HeaderWithBackButton/types'; -import SearchButton from '@components/Search/SearchRouter/SearchButton'; -import SidePanelButton from '@components/SidePanel/SidePanelButton'; - -import useDialogLabelRegistration from '@hooks/useDialogLabelRegistration'; -import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import Navigation from '@libs/Navigation/Navigation'; - -import CONST from '@src/CONST'; - -import type {SvgProps} from 'react-native-svg'; - -import {Keyboard, View} from 'react-native'; - -import HeaderActions from './layout/HeaderActions'; -import HeaderRight from './layout/HeaderRight'; -import HeaderBackButton from './primitives/HeaderBackButton'; -import HeaderCloseButton from './primitives/HeaderCloseButton'; -import HeaderDownloadButton from './primitives/HeaderDownloadButton'; -import HeaderIcon from './primitives/HeaderIcon'; -import HeaderIconButton from './primitives/HeaderIconButton'; -import HeaderThreeDotsMenu, {DEFAULT_ANCHOR_ALIGNMENT} from './primitives/HeaderThreeDotsMenu'; -import HeaderTitle from './primitives/HeaderTitle'; -import useHeaderStyles from './styles/useHeaderStyles'; - -type HeaderProps = Omit< - HeaderWithBackButtonProps, - | 'shouldDisableThreeDotsButton' - | 'threeDotsMenuIcon' - | 'threeDotsMenuIconFill' - | 'singleExecution' - | 'shouldNavigateToTopMostReport' - | 'shouldOverlay' - | 'numberOfTitleLines' - | 'parentReport' - | 'shouldShowRotateButton' - | 'onRotateButtonPress' - | 'isRotating' - | 'shouldShowPinButton' ->; - -/** - * Temporary shape wired to the legacy `HeaderWithBackButton` prop API, assembled from the primitives - * in `./primitives` and `./zones`. Its purpose is to make it visible, block by block, which composed - * piece replaces which part of the legacy render and to prove each one does so correctly, and in - * doing so, to pressure-test the primitives' own APIs so they compose with as little wrapper overhead - * as possible. - * - * Not the target shape: the next PR migrates callers away from this prop list to composing `
` - * directly from `Header.Actions`/`Header.Right` and block children. - */ -function HeaderWithBackButton({ - icon, - iconFill, - iconWidth, - iconHeight, - iconStyles, - onBackButtonPress = () => Navigation.goBack(), - onCloseButtonPress = () => Navigation.dismissModal(), - onDownloadButtonPress = () => {}, - onThreeDotsButtonPress = () => {}, - report, - policyAvatar, - policyAvatarSize = CONST.AVATAR_SIZE.DEFAULT, - shouldShowReportAvatarWithDisplay = false, - shouldDisplayStatus, - shouldShowBackButton = true, - shouldShowBorderBottom = false, - shouldShowCloseButton = false, - shouldShowDownloadButton = false, - isDownloading = false, - shouldSetModalVisibility = true, - shouldShowThreeDotsButton = false, - shouldUseHeadlineHeader = false, - stepCounter, - subtitle = '', - title = '', - titleColor, - titleStyles, - threeDotsAnchorAlignment = DEFAULT_ANCHOR_ALIGNMENT, - threeDotsMenuItems = [], - shouldEnableDetailPageNavigation = false, - children = null, - shouldOverlayDots = false, - shouldDisplayHelpButton = false, - shouldDisplaySearchRouter = false, - style, - subTitleLink = '', - shouldMinimizeMenuButton = false, - openParentReportInCurrentTab = false, - shouldSkipFocusAfterTransition = false, -}: HeaderProps) { - // Avatar-header routes skip Header, so register the dialog label here. - useDialogLabelRegistration(shouldShowReportAvatarWithDisplay ? (report?.reportName ?? '') : ''); - - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const isInLandscapeMode = useIsInLandscapeMode(); - const {containerStyle, innerRowStyle} = useHeaderStyles({shouldShowBorderBottom, style}); - - const threeDotsMenuFirstItem = threeDotsMenuItems.at(0); - const threeDotMenuTooltipsSection = ( - <> - {shouldShowThreeDotsButton && threeDotsMenuItems.length === 1 && shouldMinimizeMenuButton && !!threeDotsMenuFirstItem && ( - } - sentryLabel={threeDotsMenuFirstItem.sentryLabel} - /> - )} - {shouldShowThreeDotsButton && !(threeDotsMenuItems.length === 1 && shouldMinimizeMenuButton) && ( - - )} - {shouldShowCloseButton && ( - - )} - - ); - - return ( - Keyboard.dismiss() : undefined} - > - - {shouldShowBackButton && ( - - )} - {!!icon && ( - - )} - {!!policyAvatar && ( - - )} - {shouldShowReportAvatarWithDisplay ? ( - - ) : ( - - )} - - - {children} - {shouldShowDownloadButton && ( - - )} - - {threeDotMenuTooltipsSection} - {shouldDisplaySearchRouter && } - {shouldDisplayHelpButton && } - - - - ); -} - -export default HeaderWithBackButton; diff --git a/src/components/HeaderWithBackButtonComposed/index.tsx b/src/components/HeaderWithBackButtonComposed/index.tsx deleted file mode 100644 index 1d75016570d1..000000000000 --- a/src/components/HeaderWithBackButtonComposed/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import HeaderWithBackButtonComponent from './HeaderWithBackButton'; - -function HeaderWithBackButtonBase(props: React.ComponentProps) { - return ; -} - -const HeaderWithBackButtonComposed = Object.assign(HeaderWithBackButtonBase, {}); - -export default HeaderWithBackButtonComposed; diff --git a/src/components/HeaderWithBackButtonComposed/primitives/HeaderTitle.tsx b/src/components/HeaderWithBackButtonComposed/primitives/HeaderTitle.tsx deleted file mode 100644 index 5d568ade636c..000000000000 --- a/src/components/HeaderWithBackButtonComposed/primitives/HeaderTitle.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import HeaderTitleComponent from '@components/HeaderTitle'; - -import useLocalize from '@hooks/useLocalize'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import type {StepCounterParams} from '@src/languages/params'; - -import type {StyleProp, TextStyle} from 'react-native'; - -type HeaderTitleProps = { - /** Title of the header. */ - title: string; - - /** Subtitle of the header. */ - subtitle?: string; - - titleColor?: string; - titleStyles?: StyleProp; - - /** Data to display a step counter in the header. When set, it replaces the subtitle. */ - stepCounter?: StepCounterParams; - - /** The URL link associated with the subtitle, if available. */ - subTitleLink?: string; - - /** Whether to skip focus of the first interactive element after the RHP transition (screen reader). */ - shouldSkipFocusAfterTransition?: boolean; - - /** Whether to use the taller headline style bar with the larger title font. */ - shouldUseHeadlineHeader: boolean; -}; - -function HeaderTitle({title, subtitle = '', titleColor, titleStyles, stepCounter, subTitleLink = '', shouldSkipFocusAfterTransition = false, shouldUseHeadlineHeader}: HeaderTitleProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); - - const resolvedSubtitle = stepCounter ? translate('stepCounter', stepCounter.step, stepCounter.total, stepCounter.text) : subtitle; - - return ( - - {!!title && ( - - {title} - - )} - {!!resolvedSubtitle && {resolvedSubtitle}} - {!!subTitleLink && {subTitleLink}} - - ); -} - -export default HeaderTitle; diff --git a/src/components/HeaderWithBackButtonComposed/styles/useHeaderStyles.ts b/src/components/HeaderWithBackButtonComposed/styles/useHeaderStyles.ts deleted file mode 100644 index ed3b8cdf4c9a..000000000000 --- a/src/components/HeaderWithBackButtonComposed/styles/useHeaderStyles.ts +++ /dev/null @@ -1,28 +0,0 @@ -import useContentHeaderHeight from '@hooks/useContentHeaderHeight'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import type {StyleProp, ViewStyle} from 'react-native'; - -type ContainerStyleParams = { - /** Whether we should show a border on the bottom of the bar. */ - shouldShowBorderBottom?: boolean; - - /** Additional styles to add to the outer bar. */ - style?: StyleProp; -}; - -/** Composed styles for the Header bar + zones. */ -function useHeaderStyles({shouldShowBorderBottom = false, style}: ContainerStyleParams = {}) { - const styles = useThemeStyles(); - const {contentHeaderHeightStyle} = useContentHeaderHeight(); - - /** Outer header bar. */ - const containerStyle: StyleProp = [styles.headerBar, contentHeaderHeightStyle, shouldShowBorderBottom && styles.borderBottom, style]; - - /** Inner flex row that lays out the zones. */ - const innerRowStyle: StyleProp = [styles.dFlex, styles.flexRow, styles.alignItemsCenter, styles.flexGrow1, styles.justifyContentBetween, styles.overflowHidden, styles.mr3]; - - return {containerStyle, innerRowStyle}; -} - -export default useHeaderStyles; diff --git a/src/components/Icon/BusinessCentralSquare.tsx b/src/components/Icon/BusinessCentralSquare.tsx new file mode 100644 index 000000000000..f52360be85fb --- /dev/null +++ b/src/components/Icon/BusinessCentralSquare.tsx @@ -0,0 +1,296 @@ +import type {SvgProps} from 'react-native-svg'; + +import React, {useId} from 'react'; +import Svg, {Defs, G, LinearGradient, Path, RadialGradient, Stop} from 'react-native-svg'; + +// Static SVGs reuse gradient IDs on web. When another copy is hidden, the browser can resolve this icon's gradients +// to that copy and not draw them. This component gives each render its own gradient IDs. +function BusinessCentralSquare(props: SvgProps) { + const id = useId(); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default BusinessCentralSquare; diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index e9c4bd7dd34b..dbb862ca5000 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -138,7 +138,6 @@ import ImageCropSquareMask from '@assets/images/image-crop-square-mask.svg'; import Inbox from '@assets/images/inbox.svg'; import Info from '@assets/images/info.svg'; import BillComSquare from '@assets/images/integrationicons/bill-com-icon-square.svg'; -import BusinessCentralSquare from '@assets/images/integrationicons/business-central-icon-square.svg'; import CampfireSquare from '@assets/images/integrationicons/campfire-icon-square.svg'; import CertiniaSquare from '@assets/images/integrationicons/certinia-icon-square.svg'; import ChatGPTSquare from '@assets/images/integrationicons/chatgpt-icon-square.svg'; @@ -296,8 +295,9 @@ import Wrench from '@assets/images/wrench.svg'; import Clear from '@assets/images/x-circle.svg'; import Zoom from '@assets/images/zoom.svg'; -// These icons are React components because they have +// These icons are React components. BusinessCentralSquare needs gradient ids unique to each rendered copy, and the map icons have // drop shadow that is NOT handled by babel-plugin-transform-react-native-svg +import BusinessCentralSquare from '@components/Icon/BusinessCentralSquare'; import MapCurrentLocation from '@components/MapView/Icons/MapCurrentLocation'; import MapCurrentLocationPuck from '@components/MapView/Icons/MapCurrentLocationPuck'; import MapStartWaypoint from '@components/MapView/Icons/MapStartWaypoint'; diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index dde691ee6d16..f4a70159c5be 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -132,6 +132,7 @@ import CardReaderAlt from '@assets/images/simple-illustrations/simple-illustrati import Chair from '@assets/images/simple-illustrations/simple-illustration__chair.svg'; import Chalkboard from '@assets/images/simple-illustrations/simple-illustration__chalkboard.svg'; import Chart from '@assets/images/simple-illustrations/simple-illustration__chart.svg'; +import ChartSync from '@assets/images/simple-illustrations/simple-illustration__chartsync.svg'; import ChatBubbles from '@assets/images/simple-illustrations/simple-illustration__chatbubbles.svg'; import CheckboxText from '@assets/images/simple-illustrations/simple-illustration__checkboxtext.svg'; import CheckmarkCircle from '@assets/images/simple-illustrations/simple-illustration__checkmarkcircle.svg'; @@ -180,6 +181,7 @@ import MagnifyingGlassReceipt from '@assets/images/simple-illustrations/simple-i import Mailbox from '@assets/images/simple-illustrations/simple-illustration__mailbox.svg'; import ExpensifyMobileApp from '@assets/images/simple-illustrations/simple-illustration__mobileapp.svg'; import MoneyReceipts from '@assets/images/simple-illustrations/simple-illustration__money-receipts.svg'; +import MoneyBadge from '@assets/images/simple-illustrations/simple-illustration__moneybadge.svg'; import MoneyIntoWallet from '@assets/images/simple-illustrations/simple-illustration__moneyintowallet.svg'; import MoneyWings from '@assets/images/simple-illustrations/simple-illustration__moneywings.svg'; import MonitorSyncNo from '@assets/images/simple-illustrations/simple-illustration__monitorsync-no.svg'; @@ -358,6 +360,7 @@ const Illustrations = { CreditCardsNew, FolderOpen, FolderSync, + ChartSync, HandCard, Hashtag, InvoiceBlue, @@ -365,6 +368,7 @@ const Illustrations = { MagnifyingGlassMoney, MagnifyingGlassReceipt, MoneyReceipts, + MoneyBadge, MoneyWings, MoneyIntoWallet, MonitorSyncNo, diff --git a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx index 54113abbebe4..e90e1e05fb5a 100644 --- a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx @@ -171,6 +171,7 @@ function OptionRowLHNData({ movedToReport, currentUserAccountID, reportAttributesDerived, + visibleReportActionsData, policyTags, currentUserLogin: login ?? '', isTrackIntentUser, diff --git a/src/components/MenuItem/index.ts b/src/components/MenuItem/index.ts index e34a3b1563a6..112024d6c702 100644 --- a/src/components/MenuItem/index.ts +++ b/src/components/MenuItem/index.ts @@ -36,10 +36,12 @@ import MenuItemDescription from './leaves/content/MenuItemDescription'; import MenuItemFieldName from './leaves/content/MenuItemFieldName'; import MenuItemFieldNamePlaceholder from './leaves/content/MenuItemFieldNamePlaceholder'; import MenuItemFieldValue from './leaves/content/MenuItemFieldValue'; +import MenuItemFieldValueHTML from './leaves/content/MenuItemFieldValueHTML'; import MenuItemLabel from './leaves/content/MenuItemLabel'; import MenuItemTitle from './leaves/content/MenuItemTitle'; +import MenuItemHelpText from './leaves/helpText/MenuItemHelpText'; +import MenuItemHelpTextHTML from './leaves/helpText/MenuItemHelpTextHTML'; import MenuItemIcon from './leaves/leading/MenuItemIcon'; -import MenuItemHelpText from './leaves/MenuItemHelpText'; import MenuItemBrickRoadIndicator from './leaves/trailing/icons/MenuItemBrickRoadIndicator'; import MenuItemChevron from './leaves/trailing/icons/MenuItemChevron'; import MenuItemDownCaret from './leaves/trailing/icons/MenuItemDownCaret'; @@ -62,6 +64,7 @@ const MenuItem = Object.assign(LegacyMenuItem, { FieldName: MenuItemFieldName, FieldNamePlaceholder: MenuItemFieldNamePlaceholder, FieldValue: MenuItemFieldValue, + FieldValueHTML: MenuItemFieldValueHTML, BrickRoadIndicator: MenuItemBrickRoadIndicator, Chevron: MenuItemChevron, DownCaret: MenuItemDownCaret, @@ -70,6 +73,7 @@ const MenuItem = Object.assign(LegacyMenuItem, { Copy: MenuItemCopy, ExternalLink: MenuItemExternalLink, HelpText: MenuItemHelpText, + HelpTextHTML: MenuItemHelpTextHTML, }); export default MenuItem; diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx index d9e72e4d4bf7..cf521d34a7cf 100644 --- a/src/components/MenuItem/layout/MenuItemRoot.tsx +++ b/src/components/MenuItem/layout/MenuItemRoot.tsx @@ -21,7 +21,7 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type WithTestID from '@src/types/utils/TestID'; import type {ComponentRef, PropsWithChildren} from 'react'; -import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; +import type {AccessibilityState, GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import React, {useRef} from 'react'; import {View} from 'react-native'; @@ -46,9 +46,12 @@ type MenuItemRootProps = PropsWithChildren & * before the hover/press background so the row keeps its interaction feedback. */ style?: StyleProp; + + /** Accessibility state for the row, e.g. `{expanded}`. Lands on the pressable, which is what a screen reader focuses. */ + accessibilityState?: AccessibilityState; }; -function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel, style}: MenuItemRootProps) { +function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel, style, accessibilityState}: MenuItemRootProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const pressableRef = useRef>(null); @@ -110,6 +113,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI role={isInteractive ? CONST.ROLE.BUTTON : undefined} accessibilityLabel={accessibilityLabel ?? derivedAccessibilityLabel} accessibilityHint={accessibilityHint} + accessibilityState={accessibilityState} accessible tabIndex={isInteractive ? 0 : -1} sentryLabel={sentryLabel} diff --git a/src/components/MenuItem/leaves/MenuItemHelpText.tsx b/src/components/MenuItem/leaves/MenuItemHelpText.tsx deleted file mode 100644 index b21419c0ed30..000000000000 --- a/src/components/MenuItem/leaves/MenuItemHelpText.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import FormHelpMessage from '@components/FormHelpMessage'; - -import useThemeStyles from '@hooks/useThemeStyles'; - -import type {ReactNode} from 'react'; - -import React from 'react'; - -type MenuItemHelpTextProps = { - /** Error or hint text to render under the row. Nothing renders when it is empty */ - message?: string | ReactNode; - - /** Whether the text reads as an error (red) instead of a hint (muted) */ - isError?: boolean; -}; - -/** The trailing help line of a `MenuItem.Root`, rendered under `MenuItem.Row` and inside the row's press target */ -function MenuItemHelpText({message, isError = false}: MenuItemHelpTextProps) { - const styles = useThemeStyles(); - - return ( - - ); -} - -export default MenuItemHelpText; diff --git a/src/components/MenuItem/leaves/content/MenuItemFieldValueHTML.tsx b/src/components/MenuItem/leaves/content/MenuItemFieldValueHTML.tsx new file mode 100644 index 000000000000..06b5dde71a56 --- /dev/null +++ b/src/components/MenuItem/leaves/content/MenuItemFieldValueHTML.tsx @@ -0,0 +1,42 @@ +import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext'; +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import convertToLTR from '@libs/convertToLTR'; +import Parser from '@libs/Parser'; + +import React from 'react'; +import {View} from 'react-native'; + +type MenuItemFieldValueHTMLProps = { + /** HTML the field holds. Markdown has to be turned into HTML by the caller first, e.g. with `Parser.replace` */ + children: string; + + /** Cuts the rendered text down to this many characters, closing any tags left open */ + characterLimit?: number; +}; + +/** + * Value a field holds, given as HTML. A value with no markup renders as plain text, with its entities decoded, + * so it is safe to pass values that are only sometimes HTML. + */ +function MenuItemFieldValueHTML({children, characterLimit}: MenuItemFieldValueHTMLProps) { + const styles = useThemeStyles(); + + // The row builds its label from text, so screen readers hear the content rather than the markup + useMenuItemAccessibilityLabel('bottom', Parser.htmlToText(children)); + + // Truncation needs the value wrapped in a tag, since truncateHTML returns nothing for markup-free input + const wrappedHTML = `${children}`; + const html = characterLimit ? Parser.truncateHTML(wrappedHTML, characterLimit, {ellipsis: '...'}) : wrappedHTML; + + return ( + + {Parser.isHTML(children) ? : {convertToLTR(Parser.htmlToText(html))}} + + ); +} + +export default MenuItemFieldValueHTML; diff --git a/src/components/MenuItem/leaves/helpText/BaseMenuItemHelpText.tsx b/src/components/MenuItem/leaves/helpText/BaseMenuItemHelpText.tsx new file mode 100644 index 000000000000..e7ad90213e78 --- /dev/null +++ b/src/components/MenuItem/leaves/helpText/BaseMenuItemHelpText.tsx @@ -0,0 +1,21 @@ +import FormHelpMessage from '@components/FormHelpMessage'; + +import React from 'react'; + +import type {BaseMenuItemHelpTextProps} from './types'; + +/** Base of the help line leaves */ +function BaseMenuItemHelpText({message, children, isError = false, style}: BaseMenuItemHelpTextProps) { + return ( + + {children} + + ); +} + +export default BaseMenuItemHelpText; diff --git a/src/components/MenuItem/leaves/helpText/MenuItemHelpText.tsx b/src/components/MenuItem/leaves/helpText/MenuItemHelpText.tsx new file mode 100644 index 000000000000..f8adf00ad2b3 --- /dev/null +++ b/src/components/MenuItem/leaves/helpText/MenuItemHelpText.tsx @@ -0,0 +1,22 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {MenuItemHelpTextProps} from './types'; + +import BaseMenuItemHelpText from './BaseMenuItemHelpText'; + +/** The help line of a `MenuItem.Root`, rendered under `MenuItem.Row` and inside the row's press target */ +function MenuItemHelpText({message, isError = false}: MenuItemHelpTextProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemHelpText; diff --git a/src/components/MenuItem/leaves/helpText/MenuItemHelpTextHTML.tsx b/src/components/MenuItem/leaves/helpText/MenuItemHelpTextHTML.tsx new file mode 100644 index 000000000000..c4b9daacd792 --- /dev/null +++ b/src/components/MenuItem/leaves/helpText/MenuItemHelpTextHTML.tsx @@ -0,0 +1,29 @@ +import RenderHTML from '@components/RenderHTML'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {MenuItemHelpTextHTMLProps} from './types'; + +import BaseMenuItemHelpText from './BaseMenuItemHelpText'; + +/** + * An error or hint given as HTML, rendered after a `MenuItem.Root` so its links stay pressable + * on their own instead of being swallowed by the row's press target. + */ +function MenuItemHelpTextHTML({children, isError = false}: MenuItemHelpTextHTMLProps) { + const styles = useThemeStyles(); + const textTag = isError ? 'alert-text' : 'muted-text-label'; + + return ( + + <${textTag}>${children}`} /> + + ); +} + +export default MenuItemHelpTextHTML; diff --git a/src/components/MenuItem/leaves/helpText/types.ts b/src/components/MenuItem/leaves/helpText/types.ts new file mode 100644 index 000000000000..29ef14a9be37 --- /dev/null +++ b/src/components/MenuItem/leaves/helpText/types.ts @@ -0,0 +1,34 @@ +import type {ReactNode} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; + +/** Props shared by the help line leaves */ +type MenuItemHelpTextSharedProps = { + /** Whether the line reads as an error (red) instead of a hint (muted) */ + isError?: boolean; +}; + +/** Props of the plain text help line */ +type MenuItemHelpTextProps = MenuItemHelpTextSharedProps & { + /** Error or hint text to render under the row. Nothing renders when it is empty */ + message?: string | ReactNode; +}; + +/** Props of the HTML help line */ +type MenuItemHelpTextHTMLProps = MenuItemHelpTextSharedProps & { + /** HTML of the error or hint. Markdown has to be turned into HTML by the caller first, e.g. with `Parser.replace` */ + children: string; +}; + +/** Props of the base both help line leaves render through */ +type BaseMenuItemHelpTextProps = MenuItemHelpTextSharedProps & { + /** Text of the line. Kept apart from `children` so errors given as text still get announced */ + message?: string | ReactNode; + + /** Content of the line, rendered instead of `message` */ + children?: ReactNode; + + /** Spacing of the line, which depends on where the leaf sits */ + style?: StyleProp; +}; + +export type {BaseMenuItemHelpTextProps, MenuItemHelpTextHTMLProps, MenuItemHelpTextProps}; diff --git a/src/components/MenuItem/presets/MenuItemField.tsx b/src/components/MenuItem/presets/MenuItemField.tsx index 83022c2bccc3..889eeb5c9d75 100644 --- a/src/components/MenuItem/presets/MenuItemField.tsx +++ b/src/components/MenuItem/presets/MenuItemField.tsx @@ -8,9 +8,11 @@ import MenuItemFieldNamePlaceholder from '@components/MenuItem/leaves/content/Me import MenuItemFieldValue from '@components/MenuItem/leaves/content/MenuItemFieldValue'; import MenuItemChevron from '@components/MenuItem/leaves/trailing/icons/MenuItemChevron'; +import hasRenderableChildren from '@libs/hasRenderableChildren'; + import {callFunctionIfActionIsAllowed} from '@userActions/Session'; -import type {PropsWithChildren} from 'react'; +import type {PropsWithChildren, ReactNode} from 'react'; import React from 'react'; @@ -27,6 +29,33 @@ type MenuItemFieldRowProps = PropsWithChildren<{ type MenuItemFieldProps = Omit & Omit; +type MenuItemFieldContentProps = { + /** Name of the field */ + name: string; + + /** The value leaf, e.g. `MenuItem.FieldValue` or `MenuItem.FieldValueHTML`. Leave it out for a field not filled in yet */ + children?: ReactNode; +}; + +/** + * The content cell of a field: its name over its value, or the name alone standing in for a missing value. + * Reach for it when the value needs a leaf other than `MenuItem.FieldValue`. + */ +function MenuItemFieldContent({name, children}: MenuItemFieldContentProps) { + return ( + + {hasRenderableChildren(children) ? ( + <> + {name} + {children} + + ) : ( + {name} + )} + + ); +} + /** * The line a field preset draws, without a `MenuItem.Root` of its own. Reach for it over the * `MenuItemField` preset when the row needs siblings inside the same `Root` (an error or a hint @@ -35,17 +64,8 @@ type MenuItemFieldProps = Omit & Omit - - {value ? ( - <> - {name} - {value} - - ) : ( - {name} - )} - - {!!children && {children}} + {!!value && {value}} + {hasRenderableChildren(children) && {children}} ); } @@ -67,17 +87,13 @@ function MenuItemFieldPreset({name, value, numberOfLinesValue, children, onPress value={value} numberOfLinesValue={numberOfLinesValue} > - {(!!children || !!onPress) && ( - <> - {children} - {!!onPress && } - - )} + {children} + {!!onPress && } ); } -const MenuItemField = Object.assign(MenuItemFieldPreset, {Row: MenuItemFieldRow}); +const MenuItemField = Object.assign(MenuItemFieldPreset, {Row: MenuItemFieldRow, Content: MenuItemFieldContent}); export default MenuItemField; diff --git a/src/components/MenuItem/presets/MenuItemFieldHTML.tsx b/src/components/MenuItem/presets/MenuItemFieldHTML.tsx new file mode 100644 index 000000000000..83fbfdb0acf9 --- /dev/null +++ b/src/components/MenuItem/presets/MenuItemFieldHTML.tsx @@ -0,0 +1,55 @@ +import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; +import type {MenuItemRootProps} from '@components/MenuItem/layout/MenuItemRoot'; +import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; +import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; +import MenuItemFieldValueHTML from '@components/MenuItem/leaves/content/MenuItemFieldValueHTML'; +import MenuItemChevron from '@components/MenuItem/leaves/trailing/icons/MenuItemChevron'; + +import hasRenderableChildren from '@libs/hasRenderableChildren'; + +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import type {PropsWithChildren} from 'react'; + +import React from 'react'; + +import MenuItemField from './MenuItemField'; + +type MenuItemFieldHTMLProps = Omit & + PropsWithChildren<{ + /** Name of the field */ + name: string; + + /** HTML the field holds. Markdown has to be turned into HTML by the caller first, e.g. with `Parser.replace` */ + value?: string; + + /** Cuts the rendered value down to this many characters, closing any tags left open */ + characterLimit?: number; + }>; + +/** + * Field preset with its value given as HTML: a field name plus its rendered value. With no `value` the name takes over the row. + * `children` land in the trailing cell, next to the chevron. + */ +function MenuItemFieldHTML({name, value, characterLimit, children, onPress, isDisabled = false, sentryLabel, testID}: MenuItemFieldHTMLProps) { + return ( + + + {!!value && {value}} + {(hasRenderableChildren(children) || !!onPress) && ( + + {children} + {!!onPress && } + + )} + + + ); +} + +export default MenuItemFieldHTML; diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index a10ad657c78c..86ece35927cb 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -21,6 +21,8 @@ import NarrowPaneContext from '@libs/Navigation/AppNavigator/Navigators/NarrowPa import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; import Navigation from '@libs/Navigation/Navigation'; +import variables from '@styles/variables'; + import {areAllModalsHidden, closeTop, onModalDidClose, setCloseModal, setModalCovering, setModalVisibility, willAlertModalBecomeVisible} from '@userActions/Modal'; import CONST from '@src/CONST'; @@ -248,6 +250,11 @@ function BaseModal({ setModalHeight(e.nativeEvent.layout.height); }; + const {isInNarrowPane} = useContext(NarrowPaneContext); + + // A right docked modal opened from inside the RHP sits on the RHP's own card, so it must not dim it or add a second shadow. + const isStackedOnRHP = type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED && !isSmallScreenWidth && (isInNarrowPane || isInNarrowPaneModal) && !shouldKeepRightDockedBackdropInNarrowPane; + const { modalStyle, modalContainerStyle, @@ -276,6 +283,7 @@ function BaseModal({ }, enableEdgeToEdgeBottomSafeAreaPadding, shouldDisplayBelowModals, + isStackedOnRHP, }); // When the `enableEdgeToEdgeBottomSafeAreaPadding` prop is explicitly set, we enable edge-to-edge mode. @@ -292,16 +300,14 @@ function BaseModal({ }); const modalPaddingStyles = shouldUseModalPaddingStyle ? paddings : {paddingLeft: paddings.paddingLeft, paddingRight: paddings.paddingRight}; - const {isInNarrowPane} = useContext(NarrowPaneContext); - - const shouldSuppressRightDockedBackdrop = - type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED && !isSmallScreenWidth && (isInNarrowPane || isInNarrowPaneModal) && !shouldKeepRightDockedBackdropInNarrowPane; const isFullWidthNarrowSheet = (type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED || type === CONST.MODAL.MODAL_TYPE.CENTERED_SWIPEABLE_TO_RIGHT) && isSmallScreenWidth && !shouldKeepRightDockedBackdropInNarrowPane; + // A right docked sheet floats like the RHP on wide web, so it dims the page as lightly as the RHP does. + const rightDockedBackdropOpacity = type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED && !isSmallScreenWidth ? variables.rhpOverlayOpacity : undefined; const backdropOpacityAdjusted = - !shouldShowBackdrop && (hideBackdrop || shouldSuppressRightDockedBackdrop || isFullWidthNarrowSheet) // full-width narrow sheets (RHP-like) shouldn't dim a backdrop behind them + !shouldShowBackdrop && (hideBackdrop || isStackedOnRHP || isFullWidthNarrowSheet) // full-width narrow sheets (RHP-like) shouldn't dim a backdrop behind them ? 0 - : backdropOpacity; + : (backdropOpacity ?? rightDockedBackdropOpacity); const dragArea = type === CONST.MODAL.MODAL_TYPE.CENTERED || type === CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE ? undefined : false; diff --git a/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx b/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx index 6a3c86405d81..18e2eb82e55d 100644 --- a/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx +++ b/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx @@ -29,7 +29,7 @@ function Backdrop({ if (!backdropOpacity) { return; } - const FadeIn = new Keyframe(getModalInAnimation('fadeIn')); + const FadeIn = new Keyframe(getModalInAnimation('fadeIn', backdropOpacity)); return FadeIn.duration(animationInTiming).reduceMotion(ReduceMotion.Never); }, [animationInTiming, backdropOpacity]); @@ -37,7 +37,7 @@ function Backdrop({ if (!backdropOpacity) { return; } - const FadeOut = new Keyframe(getModalOutAnimation('fadeOut')); + const FadeOut = new Keyframe(getModalOutAnimation('fadeOut', backdropOpacity)); return FadeOut.duration(animationOutTiming).reduceMotion(ReduceMotion.Never); }, [animationOutTiming, backdropOpacity]); diff --git a/src/components/Modal/ReanimatedModal/utils.ts b/src/components/Modal/ReanimatedModal/utils.ts index c3758ed9a46b..02c26315b917 100644 --- a/src/components/Modal/ReanimatedModal/utils.ts +++ b/src/components/Modal/ReanimatedModal/utils.ts @@ -11,7 +11,7 @@ import type {AnimationIn, AnimationOut} from './types'; const easing = Easing.bezier(0.76, 0.0, 0.24, 1.0).factory(); -function getModalInAnimation(animationType: AnimationIn): ValidKeyframeProps { +function getModalInAnimation(animationType: AnimationIn, fadeOpacity: number = variables.overlayOpacity): ValidKeyframeProps { switch (animationType) { case 'slideInRight': return { @@ -33,7 +33,7 @@ function getModalInAnimation(animationType: AnimationIn): ValidKeyframeProps { return { from: {opacity: 0}, to: { - opacity: variables.overlayOpacity, + opacity: fadeOpacity, easing, }, }; @@ -69,7 +69,7 @@ function getModalInAnimationStyle(animationType: AnimationIn): (progress: number } } -function getModalOutAnimation(animationType: AnimationOut): ValidKeyframeProps { +function getModalOutAnimation(animationType: AnimationOut, fadeOpacity: number = variables.overlayOpacity): ValidKeyframeProps { switch (animationType) { case 'slideOutRight': return { @@ -89,7 +89,7 @@ function getModalOutAnimation(animationType: AnimationOut): ValidKeyframeProps { }; case 'fadeOut': return { - from: {opacity: variables.overlayOpacity}, + from: {opacity: fadeOpacity}, to: { opacity: 0, easing, diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ae0b2ffaf4a1..84c1bbdc682d 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1,5 +1,7 @@ +import useCarouselTransactionIDs from '@hooks/useCarouselTransactionIDs'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useMoneyReportHeaderMoreContentVisibility from '@hooks/useMoneyReportHeaderMoreContentVisibility'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; @@ -9,16 +11,21 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused, useRoute} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import React, {useCallback, useEffect} from 'react'; import {View} from 'react-native'; import HeaderLoadingBar from './HeaderLoadingBar'; @@ -26,6 +33,8 @@ import HeaderWithBackButton from './HeaderWithBackButton'; import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import MoneyReportHeaderModals from './MoneyReportHeaderModals'; import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent'; +import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; +import MoneyRequestReportTransactionsNavigation from './MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; import {PaymentAnimationsProvider} from './PaymentAnimationsContext'; import {useSearchSelectionActions} from './Search/SearchContext'; @@ -75,15 +84,49 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const transactions = Object.values(reportTransactions); + // The same filtered list MoneyRequestReportTransactionsNavigation renders from, so the two agree on whether + // there is an expense carousel to show. Gating on the raw Onyx list let this pick the expense branch while the + // carousel found nothing to page through, leaving the user with neither set of arrows. + const {transactionIDs: activeTransactionIDs} = useCarouselTransactionIDs(); + + const singleTransactionID = transactions.length === 1 ? transactions.at(0)?.transactionID : undefined; + + const threadParentReportActionID = moneyRequestReport?.parentReportActionID; + const threadTransactionIDSelector = useCallback( + (parentReportActions: OnyxEntry) => { + const parentReportAction = threadParentReportActionID ? parentReportActions?.[threadParentReportActionID] : undefined; + return isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; + }, + [threadParentReportActionID], + ); + const [threadTransactionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.parentReportID)}`, {selector: threadTransactionIDSelector}); + + const anchorTransactionIDFromRoute = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT ? route.params.anchorTransactionID : undefined; + const routeAnchorTransactionID = anchorTransactionIDFromRoute && activeTransactionIDs.includes(anchorTransactionIDFromRoute) ? anchorTransactionIDFromRoute : undefined; + const carouselAnchorTransactionID = route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT ? undefined : (singleTransactionID ?? threadTransactionID ?? routeAnchorTransactionID); + const shouldShowTransactionNavigation = + route.name !== SCREENS.REPORT && !!carouselAnchorTransactionID && activeTransactionIDs.length > 1 && activeTransactionIDs.includes(carouselAnchorTransactionID); + const styles = useThemeStyles(); const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout, shouldUseNarrowLayout: shouldUseNarrowLayoutOnWideRHP} = useResponsiveLayoutOnWideRHP(); const shouldShowHeaderButtonsInHeaderRow = isInLandscapeMode || !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; + const isReportInRHP = route.name !== SCREENS.REPORT; - const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; + // The report-level arrows page through search results, so they are search-only; the expense carousel takes + // precedence wherever this report is anchored to one. + const shouldShowReportNavigation = !shouldShowTransactionNavigation && isReportInSearch; + + const {statusBarType, shouldShowNextStep, hasStatusOrNextStep} = useMoneyReportHeaderMoreContentVisibility(reportIDProp); + const shouldRenderActionsInHeaderRow = shouldShowHeaderButtonsInHeaderRow && !hasStatusOrNextStep; + // A narrow header has room for one control beside the title, and prev/next wins it: the arrows are the only way + // to reach this expense's siblings, whereas the search router is still one back-press away on the screen the + // user came from. Where no arrows are rendered the magnifier keeps its place, as it did before. + const shouldDisplaySearchRouter = !isReportInRHP || (isSmallScreenWidth && !shouldShowTransactionNavigation && !shouldShowReportNavigation); + const backTo = (route.params as {backTo?: Route} | undefined)?.backTo; const primaryAction = useReportPrimaryAction(reportIDProp); @@ -138,14 +181,29 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt shouldEnableDetailPageNavigation openParentReportInCurrentTab > - {shouldShowHeaderButtonsInHeaderRow && ( - - )} + + {shouldRenderActionsInHeaderRow && ( + + )} + {shouldShowTransactionNavigation && !!carouselAnchorTransactionID ? ( + + ) : ( + shouldShowReportNavigation && ( + + ) + )} + {!shouldShowHeaderButtonsInHeaderRow && ( )} - + ); diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 923a5f07e5e5..9ee62eade7e4 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -87,6 +87,8 @@ type MoneyReportHeaderSecondaryActionsProps = { isReportInSearch?: boolean; backTo?: Route; dropdownMenuRef?: React.RefObject; + /** Disables the "More" dropdown, e.g. while expenses are selected */ + isDisabled?: boolean; }; const MORE_MENU_SUBMIT_TO_POPOVER_ANCHOR_ALIGNMENT = { @@ -94,7 +96,7 @@ const MORE_MENU_SUBMIT_TO_POPOVER_ANCHOR_ALIGNMENT = { vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, }; -function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isReportInSearch, backTo, dropdownMenuRef}: MoneyReportHeaderSecondaryActionsProps) { +function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isReportInSearch, backTo, dropdownMenuRef, isDisabled}: MoneyReportHeaderSecondaryActionsProps) { const {isPaidAnimationRunning, isApprovedAnimationRunning, startAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimationsContext(); const {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); @@ -130,6 +132,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const [invoiceReceiverPolicy] = useOnyx( `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, {}, @@ -405,6 +408,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo isChatReportArchived, isOffline, rules, + cardList, }) : []; @@ -489,6 +493,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo primaryAction={primaryAction} applicableSecondaryActions={applicableSecondaryActions} dropdownMenuRef={dropdownMenuRef} + isDisabled={isDisabled} onOptionsMenuHide={handleOptionsMenuHide} ref={kycWallRef} shouldPutHeaderTextAfterBackButton @@ -496,7 +501,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo ); } -function MoneyReportHeaderSecondaryActionsPlaceholder({primaryAction}: {primaryAction: ValueOf | ''}) { +function MoneyReportHeaderSecondaryActionsPlaceholder({primaryAction, isDisabled}: {primaryAction: ValueOf | ''; isDisabled?: boolean}) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -512,6 +517,7 @@ function MoneyReportHeaderSecondaryActionsPlaceholder({primaryAction}: {primaryA size={CONST.BUTTON_SIZE.MEDIUM} innerStyles={innerStyles} style={shouldTakeRemainingWidth ? styles.w100 : undefined} + isDisabled={isDisabled} onPress={() => {}} > {translate('common.more')} @@ -521,7 +527,7 @@ function MoneyReportHeaderSecondaryActionsPlaceholder({primaryAction}: {primaryA ); } -function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInSearch, backTo, dropdownMenuRef}: MoneyReportHeaderSecondaryActionsProps) { +function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInSearch, backTo, dropdownMenuRef, isDisabled}: MoneyReportHeaderSecondaryActionsProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout, isMediumScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const shouldTakeRemainingWidth = (shouldUseNarrowLayout || isMediumScreenWidth) && !primaryAction && !isInLandscapeMode; @@ -533,7 +539,12 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS anchorAlignment={MORE_MENU_SUBMIT_TO_POPOVER_ANCHOR_ALIGNMENT} > } + placeholder={ + + } // RHPReportScreen remounts this tree on setParams arrow-nav without firing a transition, // so we must not wait for one — see https://github.com/Expensify/App/issues/88931. waitForUpcomingTransition={false} @@ -544,6 +555,7 @@ function MoneyReportHeaderSecondaryActions({reportID, primaryAction, isReportInS isReportInSearch={isReportInSearch} backTo={backTo} dropdownMenuRef={dropdownMenuRef} + isDisabled={isDisabled} /> diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 57beb62e0202..312e84d68a57 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -26,7 +26,7 @@ import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViol import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; +import {getTotalAmountForIOUReportPreviewButton, isEveryReportTransactionSelected} from '@libs/MoneyRequestReportUtils'; import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; import {hasUpdatedTotal, shouldShowMarkAsDone} from '@libs/ReportUtils'; import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; @@ -78,6 +78,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); @@ -172,6 +173,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn isChatReportArchived, isOffline, rules, + cardList, }) : []; @@ -198,7 +200,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const isPayable = hasPayAction && canIOUBePaid; const totalAmount = getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions, convertToDisplayString); - const allExpensesSelected = selectedTransactionIDs.length > 0 && selectedTransactionIDs.length === nonPendingDeleteTransactions.length; + const allExpensesSelected = isEveryReportTransactionSelected(nonPendingDeleteTransactions, selectedTransactionIDs); // Shared payment hook const {handleWorkspaceSelected, onSelectionModePaymentSelect, selectionModeKYCSuccess, paymentSubMenuItems, hasPayInSelectionMode, kycWallRef} = useSelectionModePayment({ @@ -262,7 +264,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const submitButtonText = shouldShowMarkAsDoneCopy ? translate('common.markAsDone') : translate('common.submit'); const approveButtonText = shouldShowMarkAsDoneCopy ? translate('common.markAsDone') : translate('iou.approve'); - const selectionModeReportLevelActions: Array & Pick> = [ + const selectionModeReportLevelActions: Array & Pick> = [ ...(hasSubmitAction && !shouldBlockSubmit ? [ { diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index b65bf814f237..dd23a7573b59 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -64,11 +64,14 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch, ba clearSelectedTransactions(true); }, [transactionThreadReportID]); // eslint-disable-line react-hooks/exhaustive-deps + // Report-level actions must not be usable while expenses are selected, otherwise they get mixed up with the bulk expense actions. + const shouldDisableReportActions = hasSelectedTransactions && !isTransactionThread; + const narrowedPrimaryAction = narrowPrimaryAction(primaryAction); - // A wide layout acts on the selection through the bulk action bar floating over the list instead, which leaves the - // report's own actions in the header while a selection is being built up. - if (hasSelectedTransactions && !isTransactionThread && shouldUseNarrowLayoutOnWideRHP) { + // A wide layout acts on the selection through the bulk action bar floating over the list instead, so the report's own + // actions stay in the header but are disabled while a selection is being built up. + if (shouldDisableReportActions && shouldUseNarrowLayoutOnWideRHP) { return ( triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} + isDisabled={shouldDisableReportActions} /> )} @@ -99,6 +103,7 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch, ba isReportInSearch={isReportInSearch} backTo={backTo} dropdownMenuRef={dropdownMenuRef} + isDisabled={shouldDisableReportActions} /> ); diff --git a/src/components/MoneyReportHeaderActions/types.ts b/src/components/MoneyReportHeaderActions/types.ts index 57f4c3c02297..a5310078b942 100644 --- a/src/components/MoneyReportHeaderActions/types.ts +++ b/src/components/MoneyReportHeaderActions/types.ts @@ -6,7 +6,7 @@ import type {Route} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; -type SecondaryActionEntry = DropdownOption> & Pick; +type SecondaryActionEntry = DropdownOption> & Pick; type MoneyReportHeaderActionsProps = { reportID: string | undefined; diff --git a/src/components/MoneyReportHeaderKYCDropdown.tsx b/src/components/MoneyReportHeaderKYCDropdown.tsx index 42bf33cd382e..697cd19a137c 100644 --- a/src/components/MoneyReportHeaderKYCDropdown.tsx +++ b/src/components/MoneyReportHeaderKYCDropdown.tsx @@ -46,6 +46,9 @@ type MoneyReportHeaderKYCDropdownProps = Omit void; + + /** Disables the "More" dropdown, e.g. while expenses are selected */ + isDisabled?: boolean; }; function MoneyReportHeaderKYCDropdown({ @@ -60,6 +63,7 @@ function MoneyReportHeaderKYCDropdown({ shouldShowSuccessStyle, dropdownMenuRef, onOptionsMenuHide, + isDisabled, ref, headerText = '', shouldPutHeaderTextAfterBackButton = false, @@ -108,6 +112,7 @@ function MoneyReportHeaderKYCDropdown({ }} buttonRef={buttonRef} shouldAlwaysShowDropdownMenu + isDisabled={isDisabled} shouldPopoverUseScrollView={shouldPopoverUseScrollView(applicableSecondaryActions)} customText={customText ?? translate('common.more')} options={applicableSecondaryActions} diff --git a/src/components/MoneyReportHeaderMoreContent.tsx b/src/components/MoneyReportHeaderMoreContent.tsx index b8a5494fa99e..61be77fbf67e 100644 --- a/src/components/MoneyReportHeaderMoreContent.tsx +++ b/src/components/MoneyReportHeaderMoreContent.tsx @@ -1,17 +1,12 @@ -import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar'; import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useThemeStyles from '@hooks/useThemeStyles'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; -import {isGroupPolicy} from '@libs/PolicyUtils'; -import {isInvoiceReport as isInvoiceReportUtil} from '@libs/ReportUtils'; import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -22,20 +17,37 @@ import {useRoute} from '@react-navigation/native'; import React from 'react'; import {View} from 'react-native'; +import type {MoneyReportHeaderActionsProps} from './MoneyReportHeaderActions/types'; + +import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import MoneyReportHeaderNextStep from './MoneyReportHeaderNextStep'; import MoneyReportHeaderStatusBarSection from './MoneyReportHeaderStatusBarSection'; import {useMoneyReportTransactionThread} from './MoneyReportTransactionThreadContext'; -import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; type MoneyReportHeaderMoreContentProps = { reportID: string | undefined; + + /** The report's primary action, forwarded to the actions row */ + primaryAction: MoneyReportHeaderActionsProps['primaryAction']; + + /** Route to navigate back to */ + backTo: Route | undefined; + + /** Which status bar to render, resolved by the header via useMoneyReportHeaderMoreContentVisibility */ + statusBarType: ValueOf | undefined; + + /** Whether the next step bar should be rendered, resolved alongside `statusBarType` */ + shouldShowNextStep: boolean; + + /** Whether the report actions belong at the end of this row. The header renders them itself when this row is empty. */ + shouldRenderActionsInRow: boolean; }; /** - * Cheap visibility gate — fetches minimal data to decide whether the more-content section - * should render at all, avoiding expensive hooks in the body when nothing is shown. + * Cheap visibility gate that decides whether the more-content section should render at all, + * avoiding expensive hooks in the body when nothing is shown. */ -function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentProps) { +function MoneyReportHeaderMoreContent({reportID, primaryAction, backTo, statusBarType, shouldShowNextStep, shouldRenderActionsInRow}: MoneyReportHeaderMoreContentProps) { const route = useRoute< | PlatformStackRouteProp | PlatformStackRouteProp @@ -45,13 +57,9 @@ function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentPr const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); - const {shouldShowStatusBar, statusBarType} = useMoneyReportHeaderStatusBar(reportID, moneyRequestReport?.chatReportID); - - const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); - const shouldShowNextStep = isGroupPolicy(policy) && !isInvoiceReport && !shouldShowStatusBar; - const shouldShowMoreContent = shouldShowNextStep || !!statusBarType || isReportInSearch; + const hasStatusOrNextStep = shouldShowNextStep || !!statusBarType; + const shouldShowMoreContent = hasStatusOrNextStep || shouldRenderActionsInRow; if (!shouldShowMoreContent) { return null; @@ -63,6 +71,9 @@ function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentPr statusBarType={statusBarType} isReportInSearch={isReportInSearch} shouldShowNextStep={shouldShowNextStep} + primaryAction={primaryAction} + backTo={backTo} + shouldRenderActionsInRow={shouldRenderActionsInRow} /> ); } @@ -72,14 +83,21 @@ type MoneyReportHeaderMoreContentBodyProps = { statusBarType: ValueOf | undefined; isReportInSearch: boolean; shouldShowNextStep: boolean; + primaryAction: MoneyReportHeaderActionsProps['primaryAction']; + backTo: Route | undefined; + shouldRenderActionsInRow: boolean; }; -function MoneyReportHeaderMoreContentBody({moneyRequestReport, statusBarType, isReportInSearch, shouldShowNextStep}: MoneyReportHeaderMoreContentBodyProps) { +function MoneyReportHeaderMoreContentBody({ + moneyRequestReport, + statusBarType, + isReportInSearch, + shouldShowNextStep, + primaryAction, + backTo, + shouldRenderActionsInRow, +}: MoneyReportHeaderMoreContentBodyProps) { const styles = useThemeStyles(); - const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const shouldDisplayNarrowVersion = shouldUseNarrowLayout || isMediumScreenWidth; - const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); - const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; const reportID = moneyRequestReport?.reportID; const {iouTransactionID} = useMoneyReportTransactionThread(); @@ -94,10 +112,12 @@ function MoneyReportHeaderMoreContentBody({moneyRequestReport, statusBarType, is iouTransactionID={iouTransactionID} /> - {isReportInSearch && ( - )} diff --git a/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx index 5df69c204bf5..60d0c0df6c0b 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx @@ -23,9 +23,11 @@ import useConfirmApproval from './useConfirmApproval'; type ApprovePrimaryActionProps = { reportID: string | undefined; chatReportID: string | undefined; + /** Disables the Approve button, e.g. while expenses are selected */ + isDisabled?: boolean; }; -function ApprovePrimaryAction({reportID, chatReportID}: ApprovePrimaryActionProps) { +function ApprovePrimaryAction({reportID, chatReportID, isDisabled}: ApprovePrimaryActionProps) { const {isPaidAnimationRunning, startApprovedAnimation} = usePaymentAnimationsContext(); const currentUserDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserDetails.accountID; @@ -93,7 +95,7 @@ function ApprovePrimaryAction({reportID, chatReportID}: ApprovePrimaryActionProp moneyRequestReport={iouReport} transactions={transactions} shouldShowPayButton={shouldShowPayButton} - isDisabled={isBlockSubmitDueToPreventSelfApproval} + isDisabled={!!isBlockSubmitDueToPreventSelfApproval || isDisabled} /> ); } diff --git a/src/components/MoneyReportHeaderPrimaryAction/ExportPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/ExportPrimaryAction.tsx index a4e126caed06..bf3475eba444 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/ExportPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/ExportPrimaryAction.tsx @@ -20,9 +20,11 @@ import React from 'react'; type ExportPrimaryActionProps = { reportID: string | undefined; onExportModalOpen: () => void; + /** Disables the Export button, e.g. while expenses are selected */ + isDisabled?: boolean; }; -function ExportPrimaryAction({reportID, onExportModalOpen}: ExportPrimaryActionProps) { +function ExportPrimaryAction({reportID, onExportModalOpen, isDisabled}: ExportPrimaryActionProps) { const {translate} = useLocalize(); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); @@ -36,6 +38,7 @@ function ExportPrimaryAction({reportID, onExportModalOpen}: ExportPrimaryActionP return ( Navigation.navigate(ROUTES.DEBUG_REPORT_ACTION.getRoute(reportID, item.reportActionID))} ListItem={SingleSelectListItem} diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index 79e09dee9fb9..67b5ea28066d 100644 --- a/src/pages/Debug/Report/DebugReportPage.tsx +++ b/src/pages/Debug/Report/DebugReportPage.tsx @@ -9,6 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import {useDerivedIsEmptyReport} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -40,7 +41,6 @@ import type {ReportAttributesDerivedValue} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import {conciergePersonalDetailSelector, personalDetailsSelector} from '@selectors/PersonalDetails'; import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; @@ -87,8 +87,8 @@ function DebugReportPage({ }); const currentUserPersonalDetail = useCurrentUserPersonalDetails(); const {accountID: currentUserAccountID, login: currentUserLogin} = currentUserPersonalDetail; - const [conciergePersonalDetail] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: conciergePersonalDetailSelector}); - const [reportOwnerPersonalDetail] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsSelector(report?.ownerAccountID)}); + const [conciergePersonalDetail] = usePersonalDetail(CONST.ACCOUNT_ID.CONCIERGE); + const [reportOwnerPersonalDetail] = usePersonalDetail(report?.ownerAccountID); const transactionID = DebugUtils.getTransactionID(report, reportActions); const isReportArchived = useReportIsArchived(reportID); const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); diff --git a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx index 80732fcf8417..95d47595016c 100644 --- a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx +++ b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx @@ -112,6 +112,7 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec const [policiesWithEmptyReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: policiesWithEmptyReportsForAccountSelector}); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const navigateToNewReport = (optimisticReportID: string) => { if (isRHPOnReportInSearch) { @@ -155,6 +156,7 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec allTransactionViolation: transactionViolations, reports: reportsForCall, rules, + cardList, isTrackIntentUser, personalPolicyOutputCurrency: personalPolicy?.outputCurrency, selfDMReportActions, diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx index 1cb59a166d0f..8367aa5ce07a 100644 --- a/src/pages/DynamicReportChangeWorkspacePage.tsx +++ b/src/pages/DynamicReportChangeWorkspacePage.tsx @@ -21,6 +21,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactions from '@hooks/useReportTransactions'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; @@ -52,12 +53,12 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {doesPersonalDetailExistSelector, personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type {DismissedProductTraining} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {doesPersonalDetailExist, loginSelector} from '@selectors/PersonalDetails'; import React, {useState} from 'react'; import {View} from 'react-native'; @@ -94,9 +95,9 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace const isReportLastVisibleArchived = useReportIsArchived(report?.parentReportID); const ownerAccountID = report?.ownerAccountID; const managerID = report?.managerID; - const [submitterLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(ownerAccountID)}); - const [doesSubmitterPersonalDetailExist] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: doesPersonalDetailExistSelector(ownerAccountID)}); - const [managerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(managerID)}); + const [submitterLogin] = usePersonalDetail(ownerAccountID, loginSelector); + const [hasSubmitterPersonalDetail = false] = usePersonalDetail(ownerAccountID, doesPersonalDetailExist); + const [managerLogin] = usePersonalDetail(managerID, loginSelector); const shouldShowLoadingIndicator = isAppLoadPending && !isOffline; const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -165,7 +166,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace reportPreviewAction, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, submitterLogin, - doesSubmitterPersonalDetailExist ?? false, + hasSubmitterPersonalDetail, getCurrencyDecimals, rules, reportTransactions, diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsAvatar.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsAvatar.tsx new file mode 100644 index 000000000000..1d643d0186c6 --- /dev/null +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsAvatar.tsx @@ -0,0 +1,136 @@ +import UserAvatar from '@components/Avatar/UserAvatar'; +import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; +import ReportHeaderAvatars from '@components/ReportHeaderAvatars'; +import RoomHeaderAvatars from '@components/RoomHeaderAvatars'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; +import {isPolicyAdmin as isPolicyAdminUtil} from '@libs/PolicyUtils'; +import { + getIcons, + getParticipantsList, + isChatRoom as isChatRoomUtil, + isChatThread as isChatThreadUtil, + isGroupChat as isGroupChatUtil, + isPolicyExpenseChat as isPolicyExpenseChatUtil, + isThread as isThreadUtil, + isUserCreatedPolicyRoom as isUserCreatedPolicyRoomUtil, +} from '@libs/ReportUtils'; +import {getAccountIDFromAvatarID} from '@libs/UserAvatarUtils'; + +import {clearAvatarErrors, updateGroupChatAvatar} from '@userActions/Report'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {pendingDeleteMemberAccountIDsSelector} from '@src/selectors/ReportMetaData'; +import type {Report} from '@src/types/onyx'; + +import React from 'react'; +import {View} from 'react-native'; + +type ReportDetailsAvatarProps = { + reportID: string; +}; + +type ReportDetailsAvatarContentProps = { + report: Report; +}; + +function ReportDetailsAvatarContent({report}: ReportDetailsAvatarContentProps) { + const reportID = report.reportID; + const {translate, formatPhoneNumber} = useLocalize(); + const styles = useThemeStyles(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Camera']); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [pendingDeleteMemberAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {selector: pendingDeleteMemberAccountIDsSelector}); + const [personalDetails] = useAllPersonalDetails(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserAccountID = currentUserPersonalDetails?.accountID; + const isReportArchived = useReportIsArchived(reportID); + + const isPolicyAdmin = isPolicyAdminUtil(policy); + const isPolicyExpenseChat = isPolicyExpenseChatUtil(report); + const isChatRoom = isChatRoomUtil(report); + const isUserCreatedPolicyRoom = isUserCreatedPolicyRoomUtil(report); + const isChatThread = isChatThreadUtil(report); + const isGroupChat = isGroupChatUtil(report); + const isThread = isThreadUtil(report); + const shouldOpenRoomMembersPage = isUserCreatedPolicyRoom || isChatThread || (isPolicyExpenseChat && isPolicyAdmin); + const participants = getParticipantsList(report, personalDetails, shouldOpenRoomMembersPage); + + const icons = getIcons(report, formatPhoneNumber, translate, personalDetails, null, '', -1, policy, undefined, isReportArchived, pendingDeleteMemberAccountIDs, conciergeReportID); + + if (isChatRoom && !isThread) { + return ( + + + + ); + } + if (!isGroupChat || isThread) { + return ( + + + + ); + } + + const groupChatIcon = icons.at(0); + const groupChatAvatarSource = groupChatIcon?.source; + const groupChatAvatar = groupChatAvatarSource ? ( + + ) : null; + + return ( + Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(report.reportID))} + onImageRemoved={() => { + // Calling this without a file will remove the avatar + updateGroupChatAvatar(report.reportID, report.avatarUrl); + }} + onImageSelected={(file) => updateGroupChatAvatar(report.reportID, report.avatarUrl, file)} + editIcon={expensifyIcons.Camera} + editIconStyle={styles.smallEditIconAccount} + pendingAction={report.pendingFields?.avatar ?? undefined} + errors={report.errorFields?.avatar ?? null} + errorRowStyles={styles.mt6} + onErrorClose={() => clearAvatarErrors(report.reportID)} + style={[styles.w100, styles.mb3]} + /> + ); +} + +function ReportDetailsAvatar({reportID}: ReportDetailsAvatarProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + if (!report?.reportID) { + return null; + } + + return ; +} + +export default ReportDetailsAvatar; diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsDescription.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsDescription.tsx new file mode 100644 index 000000000000..38a7712154c8 --- /dev/null +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsDescription.tsx @@ -0,0 +1,69 @@ +import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import {canEditReportDescription as canEditReportDescriptionUtil, getReportDescription, isChatRoom as isChatRoomUtil, isTaskReport as isTaskReportUtil} from '@libs/ReportUtils'; + +import {canModifyTask} from '@userActions/Task'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; + +import React from 'react'; + +type ReportDetailsDescriptionProps = { + reportID: string; +}; + +function ReportDetailsDescription({reportID}: ReportDetailsDescriptionProps) { + const {translate} = useLocalize(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserAccountID = currentUserPersonalDetails?.accountID; + const isParentReportArchived = useReportIsArchived(parentReport?.reportID); + + if (!report?.reportID) { + return null; + } + + const isChatRoom = isChatRoomUtil(report); + const isTaskReport = isTaskReportUtil(report); + const isTaskModifiable = canModifyTask(report, currentUserAccountID, isParentReportArchived); + const canEditReportDescription = canEditReportDescriptionUtil(report, policy); + const shouldShowReportDescription = isChatRoom && (canEditReportDescription || report.description !== '') && (isTaskReport ? isTaskModifiable : true); + + if (!shouldShowReportDescription) { + return null; + } + + const mentionReportContextValue = {currentReportID: report.reportID, exactlyMatch: true}; + + return ( + + + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_DESCRIPTION.path))}> + + + {!!getReportDescription(report) && {getReportDescription(report)}} + + + + + + + + + ); +} + +export default ReportDetailsDescription; diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsNameSection.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsNameSection.tsx new file mode 100644 index 000000000000..a9a74b039c76 --- /dev/null +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsNameSection.tsx @@ -0,0 +1,140 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useParentReportAction from '@hooks/useParentReportAction'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import { + getChatRoomSubtitle, + getPolicyName, + isDefaultRoom as isDefaultRoomUtil, + isExpenseReport as isExpenseReportUtil, + isGroupChat as isGroupChatUtil, + isInvoiceRoom as isInvoiceRoomUtil, + isInvoiceReport as isInvoiceReportUtil, + isMoneyRequest as isMoneyRequestUtil, + isMoneyRequestReport as isMoneyRequestReportUtil, + isPolicyExpenseChat as isPolicyExpenseChatUtil, + isThread as isThreadUtil, + isTrackExpenseReportNew as isTrackExpenseReportUtil, + isUserCreatedPolicyRoom as isUserCreatedPolicyRoomUtil, + isWorkspaceChat as isWorkspaceChatUtil, + shouldDisableRename as shouldDisableRenameUtil, +} from '@libs/ReportUtils'; +import StringUtils from '@libs/StringUtils'; + +import {clearPolicyRoomNameErrors} from '@userActions/Report'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Report} from '@src/types/onyx'; + +import React from 'react'; +import {View} from 'react-native'; + +import getReportDetailsCaseID from './getReportDetailsCaseID'; +import {CASES} from './types'; +import useReportDetailsReportName from './useReportDetailsReportName'; + +type ReportDetailsNameSectionProps = { + reportID: string; +}; + +type ReportDetailsNameSectionContentProps = { + report: Report; +}; + +function ReportDetailsNameSectionContent({report}: ReportDetailsNameSectionContentProps) { + const reportID = report.reportID; + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const parentReportAction = useParentReportAction(report); + const isReportArchived = useReportIsArchived(reportID); + const {reportName} = useReportDetailsReportName(report, parentReport, parentReportAction); + + const isGroupChat = isGroupChatUtil(report); + const isThread = isThreadUtil(report); + const isWorkspaceChat = isWorkspaceChatUtil(report.chatType ?? ''); + const shouldDisableRename = shouldDisableRenameUtil(report, isReportArchived); + const chatRoomSubtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, false, isReportArchived) ?? ''; + const additionalRoomDetails = + isExpenseReportUtil(report) || isPolicyExpenseChatUtil(report) || isInvoiceRoomUtil(report) ? chatRoomSubtitle : `${translate('threads.in')} ${chatRoomSubtitle}`; + const caseID = getReportDetailsCaseID({ + isMoneyRequestReport: isMoneyRequestReportUtil(report), + isInvoiceReport: isInvoiceReportUtil(report), + isMoneyRequest: isMoneyRequestUtil(report), + isTrackExpenseReport: isTrackExpenseReportUtil(report, parentReport, parentReportAction), + }); + + let roomDescription: string | undefined; + if (caseID === CASES.MONEY_REQUEST) { + roomDescription = translate('common.name'); + } else if (isGroupChat) { + roomDescription = translate('newRoomPage.groupName'); + } else { + roomDescription = translate('newRoomPage.roomName'); + } + + const shouldDisplayGroupWorkspaceAsPushRow = !isThread && (isGroupChat || isUserCreatedPolicyRoomUtil(report) || isDefaultRoomUtil(report)); + + return ( + clearPolicyRoomNameErrors(report.reportID)} + > + + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_SETTINGS_NAME.path)); + }} + numberOfLinesTitle={isThread ? 2 : 0} + shouldBreakWord + /> + {shouldDisplayGroupWorkspaceAsPushRow && !isGroupChat && ( + + )} + + + ); +} + +/** Name row, plus the workspace row for rooms, rendered for every report that is not an expense, invoice or money request report */ +function ReportDetailsNameSection({reportID}: ReportDetailsNameSectionProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + if (!report?.reportID) { + return null; + } + + return ; +} + +export default ReportDetailsNameSection; diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsPromotedActions.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsPromotedActions.tsx new file mode 100644 index 000000000000..0f08de1f9fdd --- /dev/null +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsPromotedActions.tsx @@ -0,0 +1,46 @@ +import type {PromotedAction} from '@components/PromotedActionsBar'; +import PromotedActionsBar, {PromotedActions} from '@components/PromotedActionsBar'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import useParentReportAction from '@hooks/useParentReportAction'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {canJoinChat} from '@libs/ReportUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; + +type ReportDetailsPromotedActionsProps = { + reportID: string; +}; + +function ReportDetailsPromotedActions({reportID}: ReportDetailsPromotedActionsProps) { + const styles = useThemeStyles(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); + const parentReportAction = useParentReportAction(report); + const isReportArchived = useReportIsArchived(reportID); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserAccountID = currentUserPersonalDetails?.accountID; + + if (!report?.reportID) { + return null; + } + + const canJoin = canJoinChat(report, parentReportAction, policy, parentReport, isReportArchived); + + const promotedActions: PromotedAction[] = [...(canJoin ? [PromotedActions.join(report, currentUserAccountID)] : []), PromotedActions.pin(report), PromotedActions.share()]; + + return ( + + ); +} + +export default ReportDetailsPromotedActions; diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx new file mode 100644 index 000000000000..d28c4df3a0e4 --- /dev/null +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx @@ -0,0 +1,156 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ParentNavigationSubtitle from '@components/ParentNavigationSubtitle'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useParentReportAction from '@hooks/useParentReportAction'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import { + canEditReportTitle, + getAvailableReportFields, + getParentNavigationSubtitle, + getReportFieldKey, + isInvoiceReport as isInvoiceReportUtil, + isMoneyRequest as isMoneyRequestUtil, + isMoneyRequestReport as isMoneyRequestReportUtil, + isReportFieldDisabled, + isReportFieldOfTypeTitle, + isTaskReport as isTaskReportUtil, + isTrackExpenseReportNew as isTrackExpenseReportUtil, +} from '@libs/ReportUtils'; + +import {clearPolicyRoomNameErrors} from '@userActions/Report'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import React from 'react'; +import {View} from 'react-native'; + +import getReportDetailsCaseID from './getReportDetailsCaseID'; +import {CASES} from './types'; +import useReportDetailsReportName from './useReportDetailsReportName'; + +type ReportDetailsTitleSectionProps = { + reportID: string; +}; + +type ReportDetailsTitleSectionContentProps = { + report: OnyxTypes.Report; +}; + +function ReportDetailsTitleSectionContent({report}: ReportDetailsTitleSectionContentProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserAccountID = currentUserPersonalDetails?.accountID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const parentReportAction = useParentReportAction(report); + const isParentReportArchived = useReportIsArchived(parentReport?.reportID); + const {reportName, derivedParentReportName} = useReportDetailsReportName(report, parentReport, parentReportAction); + + const isMoneyRequestReport = isMoneyRequestReportUtil(report); + const isInvoiceReport = isInvoiceReportUtil(report); + const isMoneyRequest = isMoneyRequestUtil(report); + const isTaskReport = isTaskReportUtil(report); + const caseID = getReportDetailsCaseID({ + isMoneyRequestReport, + isInvoiceReport, + isMoneyRequest, + isTrackExpenseReport: isTrackExpenseReportUtil(report, parentReport, parentReportAction), + }); + const parentNavigationSubtitleData = getParentNavigationSubtitle(report, policy, conciergeReportID, translate, derivedParentReportName, isParentReportArchived); + + const titleField: OnyxTypes.PolicyReportField | undefined = (() => { + const fields = getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})); + return fields.find((reportField) => isReportFieldOfTypeTitle(reportField)); + })(); + const fieldKey = getReportFieldKey(titleField?.fieldID); + const isFieldDisabled = isReportFieldDisabled(report, titleField, policy, rules); + + const shouldShowEditableTitleField = caseID !== CASES.MONEY_REQUEST && canEditReportTitle(report, policy, currentUserAccountID, rules); + + const shouldShowFurtherDetailsContent = + !isEmptyObject(parentNavigationSubtitleData) && (shouldShowEditableTitleField || isMoneyRequestReport || isInvoiceReport || isMoneyRequest || isTaskReport); + + return ( + <> + clearPolicyRoomNameErrors(report.reportID)} + > + + { + if (!report.policyID) { + return; + } + + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EDIT_REPORT_FIELD.getRoute(report.policyID, CONST.REPORT_FIELD_TITLE_FIELD_ID))); + } + : undefined + } + /> + + + {shouldShowFurtherDetailsContent && ( + + } + description={translate('threads.from')} + descriptionTextStyle={[styles.mutedNormalTextLabel, styles.mb1]} + shouldCheckActionAllowedOnPress={false} + /> + )} + + ); +} + +/** Title row backed by the workspace title report field, plus the "From" row, rendered for expense, invoice and money request reports */ +function ReportDetailsTitleSection({reportID}: ReportDetailsTitleSectionProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + if (!report?.reportID) { + return null; + } + + return ; +} + +export default ReportDetailsTitleSection; diff --git a/src/pages/DynamicReportDetailsPage/getReportDetailsCaseID.ts b/src/pages/DynamicReportDetailsPage/getReportDetailsCaseID.ts new file mode 100644 index 000000000000..a416a37741f7 --- /dev/null +++ b/src/pages/DynamicReportDetailsPage/getReportDetailsCaseID.ts @@ -0,0 +1,28 @@ +/** + * Picks which header DynamicReportDetailsPage renders (HeaderView, MoneyRequestHeader or MoneyReportHeader) from the report's type flags. + */ +import type {CaseID} from './types'; + +import {CASES} from './types'; + +type ReportDetailsCaseFlags = { + isMoneyRequestReport: boolean; + isInvoiceReport: boolean; + isMoneyRequest: boolean; + isTrackExpenseReport: boolean; +}; + +function getReportDetailsCaseID({isMoneyRequestReport, isInvoiceReport, isMoneyRequest, isTrackExpenseReport}: ReportDetailsCaseFlags): CaseID { + if (isMoneyRequestReport || isInvoiceReport) { + // 3. MoneyReportHeader + return CASES.MONEY_REPORT; + } + if (isMoneyRequest || isTrackExpenseReport) { + // 2. MoneyRequestHeader + return CASES.MONEY_REQUEST; + } + // 1. HeaderView + return CASES.DEFAULT; +} + +export default getReportDetailsCaseID; diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage/index.tsx similarity index 72% rename from src/pages/DynamicReportDetailsPage.tsx rename to src/pages/DynamicReportDetailsPage/index.tsx index 5d5279eeea25..c834a2239c09 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage/index.tsx @@ -1,18 +1,9 @@ -import UserAvatar from '@components/Avatar/UserAvatar'; -import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ParentNavigationSubtitle from '@components/ParentNavigationSubtitle'; -import type {PromotedAction} from '@components/PromotedActionsBar'; -import PromotedActionsBar, {PromotedActions} from '@components/PromotedActionsBar'; -import ReportHeaderAvatars from '@components/ReportHeaderAvatars'; -import RoomHeaderAvatars from '@components/RoomHeaderAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import {useSearchSelectionActions} from '@components/Search/SearchContext'; @@ -35,8 +26,8 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; -import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -51,32 +42,18 @@ import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {ReportDetailsNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; -import Parser from '@libs/Parser'; import Permissions from '@libs/Permissions'; import {isPolicyAdmin as isPolicyAdminUtil, isPolicyEmployee as isPolicyEmployeeUtil, shouldShowPolicy} from '@libs/PolicyUtils'; import {getOneTransactionThreadReportID, getOriginalMessage, getTrackExpenseActionableWhisper, isDeletedAction, isMoneyRequestAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; -import {getReportNameFromNames} from '@libs/ReportAttributesUtils'; -import {getReportName} from '@libs/ReportNameUtils'; import { - canDeleteCardTransactionByLiabilityType, + canDeleteCardTransaction, canDeleteTransaction, - canEditReportDescription as canEditReportDescriptionUtil, - canEditReportTitle, - canJoinChat, canLeaveChat, canWriteInReport, findLastAccessedReport, - getAvailableReportFields, - getChatRoomSubtitle, - getIcons, getOriginalReportID, - getParentNavigationSubtitle, getParticipantsAccountIDsForDisplay, getParticipantsList, - getPolicyName, - getReportDescription, - getReportFieldKey, - getReportForHeader, isArchivedNonExpenseReport, isCanceledTaskReport as isCanceledTaskReportUtil, isChatRoom as isChatRoomUtil, @@ -85,67 +62,46 @@ import { isCompletedTaskReport, isConciergeChatReport, isDefaultRoom as isDefaultRoomUtil, - isExpenseReport as isExpenseReportUtil, isFinancialReportsForBusinesses as isFinancialReportsForBusinessesUtil, isGroupChat as isGroupChatUtil, isHiddenForCurrentUser, isInvoiceReport as isInvoiceReportUtil, - isInvoiceRoom as isInvoiceRoomUtil, isMoneyRequest as isMoneyRequestUtil, isMoneyRequestReport as isMoneyRequestReportUtil, isPolicyExpenseChat as isPolicyExpenseChatUtil, isPublicRoom as isPublicRoomUtil, - isReportFieldDisabled, - isReportFieldOfTypeTitle, isRootGroupChat as isRootGroupChatUtil, isSelfDM as isSelfDMUtil, isSystemChat as isSystemChatUtil, isTaskReport as isTaskReportUtil, - isThread as isThreadUtil, isTrackExpenseReportNew as isTrackExpenseReportUtil, isUserCreatedPolicyRoom as isUserCreatedPolicyRoomUtil, - isWorkspaceChat as isWorkspaceChatUtil, isWorkspaceMemberLeavingWorkspaceRoom as isWorkspaceMemberLeavingWorkspaceRoomUtil, navigateBackOnDeleteTransaction, navigateToPrivateNotes, - shouldDisableRename as shouldDisableRenameUtil, } from '@libs/ReportUtils'; -import StringUtils from '@libs/StringUtils'; -import {getDeleteConfirmationPrompt, getDeleteExpenseTitle, getOriginalTransactionWithSplitInfo, isDemoTransaction} from '@libs/TransactionUtils'; -import {getAccountIDFromAvatarID} from '@libs/UserAvatarUtils'; +import {getDeleteConfirmationPrompt, getDeleteExpenseTitle, getOriginalTransactionWithSplitInfo, isDemoTransaction, isManagedCardTransaction} from '@libs/TransactionUtils'; + +import type {WithReportOrNotFoundProps} from '@pages/inbox/report/withReportOrNotFound'; +import withReportOrNotFound from '@pages/inbox/report/withReportOrNotFound'; import {getNavigationUrlOnMoneyRequestDelete} from '@userActions/IOU/DeleteMoneyRequest'; import {createDraftTransactionAndNavigateToParticipantSelector} from '@userActions/IOU/StartExpenseFlows'; import {deleteTrackExpense, getNavigationUrlAfterTrackExpenseDelete} from '@userActions/IOU/TrackExpense'; -import { - clearAvatarErrors, - clearPolicyRoomNameErrors, - getReportPrivateNote, - hasErrorInPrivateNotes, - leaveGroupChat, - leaveRoom, - setDeleteTransactionNavigateBackUrl, - updateGroupChatAvatar, -} from '@userActions/Report'; +import {getReportPrivateNote, hasErrorInPrivateNotes, leaveGroupChat, leaveRoom, setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import {canActionTask, canModifyTask, reopenTask} from '@userActions/Task'; import {deleteTask} from '@userActions/TaskDeletion'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import {pendingDeleteMemberAccountIDsSelector} from '@src/selectors/ReportMetaData'; import type * as OnyxTypes from '@src/types/onyx'; -import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type IconAsset from '@src/types/utils/IconAsset'; -import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; import {StackActions, useFocusEffect} from '@react-navigation/native'; import {delegateEmailSelector} from '@selectors/Account'; @@ -155,34 +111,20 @@ import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; -import type {WithReportOrNotFoundProps} from './inbox/report/withReportOrNotFound'; +import type {DynamicReportDetailsPageMenuItem} from './types'; -import withReportOrNotFound from './inbox/report/withReportOrNotFound'; - -type DynamicReportDetailsPageMenuItem = { - key: DeepValueOf; - translationKey: TranslationPaths; - icon: IconAsset; - isAnonymousAction: boolean; - action: () => void; - brickRoadIndicator?: ValueOf; - subtitle?: number; - shouldShowRightIcon?: boolean; - subtitleStyle?: StyleProp; -}; +import getReportDetailsCaseID from './getReportDetailsCaseID'; +import ReportDetailsAvatar from './ReportDetailsAvatar'; +import ReportDetailsDescription from './ReportDetailsDescription'; +import ReportDetailsNameSection from './ReportDetailsNameSection'; +import ReportDetailsPromotedActions from './ReportDetailsPromotedActions'; +import ReportDetailsTitleSection from './ReportDetailsTitleSection'; +import {CASES} from './types'; type DynamicReportDetailsPageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; -const CASES = { - DEFAULT: 'default', - MONEY_REQUEST: 'money_request', - MONEY_REPORT: 'money_report', -}; - -type CaseID = ValueOf; - function DynamicReportDetailsPage({policy, report, route, reportMetadata, reportLoadingState}: DynamicReportDetailsPageProps) { - const {translate, formatPhoneNumber} = useLocalize(); + const {translate} = useLocalize(); const {isOffline} = useNetwork(); const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); const activePolicy = useActivePolicy(); @@ -199,7 +141,6 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report 'Building', 'Exit', 'Bug', - 'Camera', 'Trashcan', 'ArrowSplit', 'Hashtag', @@ -220,7 +161,6 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const [pendingDeleteMemberAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {selector: pendingDeleteMemberAccountIDsSelector}); const {reportActions} = usePaginatedReportActions(report.reportID); const [reportActionsForOriginalReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`); @@ -237,7 +177,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionThreadReportID)}`); const [isDebugModeEnabled = false] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [personalDetails] = useAllPersonalDetails(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); @@ -254,11 +194,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(preferredPolicyID)}`, {selector: billingRestrictionPolicySelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const {showConfirmModal} = useConfirmModal(); - const reportForHeader = getReportForHeader(report, parentReport); - const derivedReportNames = useDerivedReportNamesByReportIDs([report?.parentReportID, reportForHeader?.reportID]); - const derivedParentReportName = getReportNameFromNames(derivedReportNames, report?.parentReportID); - const derivedHeaderReportName = getReportNameFromNames(derivedReportNames, reportForHeader?.reportID); const isPolicyAdmin = isPolicyAdminUtil(policy); const isPolicyEmployee = isPolicyEmployeeUtil(report?.policyID, policy); const isPolicyExpenseChat = isPolicyExpenseChatUtil(report); @@ -270,7 +207,6 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const isMoneyRequest = isMoneyRequestUtil(report); const isInvoiceReport = isInvoiceReportUtil(report); const isFinancialReportsForBusinesses = isFinancialReportsForBusinessesUtil(report); - const isInvoiceRoom = isInvoiceRoomUtil(report); const isTaskReport = isTaskReportUtil(report); const isSelfDM = isSelfDMUtil(report); const isTrackExpenseReport = isTrackExpenseReportUtil(report, parentReport, parentReportAction); @@ -278,39 +214,21 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const isParentReportArchived = useReportIsArchived(parentReport?.reportID); const isTaskModifiable = canModifyTask(report, currentUserAccountID, isParentReportArchived); const isTaskActionable = canActionTask(report, parentReportAction, currentUserAccountID, parentReport, isParentReportArchived); - const canEditReportDescription = canEditReportDescriptionUtil(report, policy); - const shouldShowReportDescription = isChatRoom && (canEditReportDescription || report.description !== '') && (isTaskReport ? isTaskModifiable : true); const isExpenseReport = isMoneyRequestReport || isInvoiceReport || isMoneyRequest; const isSingleTransactionView = isMoneyRequest || isTrackExpenseReport; const isSelfDMTrackExpenseReport = isTrackExpenseReport && isSelfDMUtil(parentReport); const isReportArchived = useReportIsArchived(report?.reportID); const isArchivedRoom = isArchivedNonExpenseReport(report, isReportArchived); - const shouldDisableRename = shouldDisableRenameUtil(report, isReportArchived); - const parentNavigationSubtitleData = getParentNavigationSubtitle(report, policy, conciergeReportID, translate, derivedParentReportName, isParentReportArchived); const base62ReportID = getBase62ReportID(Number(report.reportID)); const ancestors = useAncestors(report); - const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, false, isReportArchived); - const chatRoomSubtitle = subtitle ?? ''; - const isSystemChat = isSystemChatUtil(report); const isGroupChat = isGroupChatUtil(report); const isRootGroupChat = isRootGroupChatUtil(report, isReportArchived); - const isThread = isThreadUtil(report); const shouldOpenRoomMembersPage = isUserCreatedPolicyRoom || isChatThread || (isPolicyExpenseChat && isPolicyAdmin); const participants = getParticipantsList(report, personalDetails, shouldOpenRoomMembersPage); - let caseID: CaseID; - if (isMoneyRequestReport || isInvoiceReport) { - // 3. MoneyReportHeader - caseID = CASES.MONEY_REPORT; - } else if (isSingleTransactionView) { - // 2. MoneyRequestHeader - caseID = CASES.MONEY_REQUEST; - } else { - // 1. HeaderView - caseID = CASES.DEFAULT; - } + const caseID = getReportDetailsCaseID({isMoneyRequestReport, isInvoiceReport, isMoneyRequest, isTrackExpenseReport}); // Get the active chat members by filtering out the pending members with delete action const activeChatMembers = participants.flatMap((accountID) => { @@ -344,15 +262,6 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const isMoneyRequestReportArchived = useReportIsArchived(moneyRequestReport?.reportID); const [moneyRequestReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); - const shouldShowTaskDeleteButton = - isTaskReport && - !isCanceledTaskReport && - canWriteInReport(report) && - report.stateNum !== CONST.REPORT.STATE_NUM.APPROVED && - !isClosedReport(report) && - isTaskModifiable && - isTaskActionable; - const canDeleteRequest = isActionOwner && (canDeleteTransaction(moneyRequestReport, rules, isMoneyRequestReportArchived) || isSelfDMTrackExpenseReport) && !isDeletedParentAction; const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; const [iouTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); const [iouOriginalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransaction?.comment?.originalTransactionID)}`); @@ -364,8 +273,21 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report reportActions: requestParentReportAction ? [requestParentReportAction] : [], policy, }); - const isCardTransactionCanBeDeleted = canDeleteCardTransactionByLiabilityType(iouTransaction); - const shouldShowDeleteButton = shouldShowTaskDeleteButton || (canDeleteRequest && isCardTransactionCanBeDeleted) || isDemoTransaction(iouTransaction); + + const shouldShowTaskDeleteButton = + isTaskReport && + !isCanceledTaskReport && + canWriteInReport(report) && + report.stateNum !== CONST.REPORT.STATE_NUM.APPROVED && + !isClosedReport(report) && + isTaskModifiable && + isTaskActionable; + const canDeleteRequest = + (isActionOwner || (isPolicyAdmin && isManagedCardTransaction(iouTransaction))) && + (canDeleteTransaction(moneyRequestReport, rules, isMoneyRequestReportArchived) || isSelfDMTrackExpenseReport) && + !isDeletedParentAction; + const shouldShowDeleteButton = shouldShowTaskDeleteButton || (canDeleteRequest && canDeleteCardTransaction(iouTransaction, policy, cardList)) || isDemoTransaction(iouTransaction); + const shouldShowEditSplitOnDeleteAction = iouTransactionID ? shouldOpenSplitExpenseEditFlowOnDelete([iouTransactionID]) : false; let deleteMenuItemTitle = translate('reportActionContextMenu.deleteAction', requestParentReportAction); if (shouldShowEditSplitOnDeleteAction) { @@ -373,7 +295,6 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report } else if (caseID === CASES.DEFAULT) { deleteMenuItemTitle = translate('common.delete'); } - const isWorkspaceChat = isWorkspaceChatUtil(report?.chatType ?? ''); useEffect(() => { // Do not fetch private notes if the feature is disabled, isLoadingPrivateNotes is already defined, the network is offline, or if the report is a self DM. @@ -388,12 +309,21 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report // Resolve on tap from the module-scoped copies so this large page does not subscribe to whole collections. const lastAccessedReportID = findLastAccessedReport(false, guideAccountIDs, false, report.reportID)?.reportID; if (isRootGroupChat) { - leaveGroupChat(report, quickAction?.chatReportID?.toString() === report.reportID, currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, lastAccessedReportID); + leaveGroupChat( + report, + quickAction?.chatReportID?.toString() === report.reportID, + currentUserAccountID, + conciergeReportID, + introSelected, + isSelfTourViewed, + personalDetails, + lastAccessedReportID, + ); return; } const isWorkspaceMemberLeavingWorkspaceRoom = isWorkspaceMemberLeavingWorkspaceRoomUtil(report, isPolicyEmployee, isPolicyAdmin); - leaveRoom(report, currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, isWorkspaceMemberLeavingWorkspaceRoom, lastAccessedReportID); + leaveRoom(report, currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, personalDetails, isWorkspaceMemberLeavingWorkspaceRoom, lastAccessedReportID); }; const showLastMemberLeavingModal = async () => { @@ -422,20 +352,6 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const shouldShowGoToRoom = (isChatRoom || isPolicyExpenseChat) && !isRoomCurrentlyOpen; const shouldShowGoToWorkspace = shouldShowPolicy(policy, false, currentUserEmail) && !policy?.isJoinRequestPending && !shouldShowGoToRoom; - const shouldParseFullTitle = parentReportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && !isGroupChat; - const rawReportName = getReportName(reportForHeader, derivedHeaderReportName); - const reportName = shouldParseFullTitle ? Parser.htmlToText(rawReportName) : rawReportName; - const additionalRoomDetails = isExpenseReportUtil(report) || isPolicyExpenseChat || isInvoiceRoom ? chatRoomSubtitle : `${translate('threads.in')} ${chatRoomSubtitle}`; - - let roomDescription: string | undefined; - if (caseID === CASES.MONEY_REQUEST) { - roomDescription = translate('common.name'); - } else if (isGroupChat) { - roomDescription = translate('newRoomPage.groupName'); - } else { - roomDescription = translate('newRoomPage.roomName'); - } - const shouldShowNotificationPref = !isMoneyRequestReport && !isHiddenForCurrentUser(report); const shouldShowWriteCapability = !isMoneyRequestReport; const shouldShowMenuItem = shouldShowNotificationPref || shouldShowWriteCapability || (!!report?.visibility && report.chatType !== CONST.REPORT.CHAT_TYPE.INVOICE); @@ -722,185 +638,6 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report return items; })(); - const icons = getIcons(report, formatPhoneNumber, translate, personalDetails, null, '', -1, policy, undefined, isReportArchived, pendingDeleteMemberAccountIDs, conciergeReportID); - - const renderedAvatar = (() => { - if (isChatRoom && !isThread) { - return ( - - - - ); - } - if (!isGroupChat || isThread) { - return ( - - - - ); - } - - const groupChatIcon = icons.at(0); - const groupChatAvatarSource = groupChatIcon?.source; - const groupChatAvatar = groupChatAvatarSource ? ( - - ) : null; - - return ( - Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(report.reportID))} - onImageRemoved={() => { - // Calling this without a file will remove the avatar - updateGroupChatAvatar(report.reportID, report.avatarUrl); - }} - onImageSelected={(file) => updateGroupChatAvatar(report.reportID, report.avatarUrl, file)} - editIcon={expensifyIcons.Camera} - editIconStyle={styles.smallEditIconAccount} - pendingAction={report.pendingFields?.avatar ?? undefined} - errors={report.errorFields?.avatar ?? null} - errorRowStyles={styles.mt6} - onErrorClose={() => clearAvatarErrors(report.reportID)} - style={[styles.w100, styles.mb3]} - /> - ); - })(); - - const canJoin = canJoinChat(report, parentReportAction, policy, parentReport, !!reportNameValuePairs?.private_isArchived); - - const promotedActions = (() => { - const result: PromotedAction[] = []; - - if (canJoin) { - result.push(PromotedActions.join(report, currentUserAccountID)); - } - - if (report) { - result.push(PromotedActions.pin(report)); - } - - result.push(PromotedActions.share()); - - return result; - })(); - - const shouldDisplayGroupWorkspaceAsPushRow = !isThread && (isGroupChat || isUserCreatedPolicyRoom || isDefaultRoom); - const nameSectionGroupWorkspace = ( - clearPolicyRoomNameErrors(report?.reportID)} - > - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_SETTINGS_NAME.path)); - }} - numberOfLinesTitle={isThread ? 2 : 0} - shouldBreakWord - /> - {shouldDisplayGroupWorkspaceAsPushRow && !isGroupChat && ( - - )} - - - ); - - const titleField: OnyxTypes.PolicyReportField | undefined = (() => { - const fields = getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})); - return fields.find((reportField) => isReportFieldOfTypeTitle(reportField)); - })(); - const fieldKey = getReportFieldKey(titleField?.fieldID); - const isFieldDisabled = isReportFieldDisabled(report, titleField, policy, rules); - - const shouldShowEditableTitleField = caseID !== CASES.MONEY_REQUEST && canEditReportTitle(report, policy, currentUserAccountID, rules); - - const nameSectionFurtherDetailsContent = ( - - } - description={translate('threads.from')} - descriptionTextStyle={[styles.mutedNormalTextLabel, styles.mb1]} - shouldCheckActionAllowedOnPress={false} - /> - ); - - const nameSectionTitleField = ( - clearPolicyRoomNameErrors(report.reportID)} - > - - { - if (!report?.policyID) { - return; - } - - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EDIT_REPORT_FIELD.getRoute(report.policyID, CONST.REPORT_FIELD_TITLE_FIELD_ID))); - } - : undefined - } - /> - - - ); - const deleteTransaction = () => { if (caseID === CASES.DEFAULT) { deleteTask( @@ -1063,11 +800,6 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report }); }; - const mentionReportContextValue = {currentReportID: report.reportID, exactlyMatch: true}; - - const shouldShowFurtherDetailsContent = - !isEmptyObject(parentNavigationSubtitleData) && (shouldShowEditableTitleField || isMoneyRequestReport || isInvoiceReport || isMoneyRequest || isTaskReport); - return ( @@ -1076,29 +808,12 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report onBackButtonPress={() => Navigation.goBack(navigateBackFromReportDetailsPath)} /> - {renderedAvatar} - {isExpenseReport && nameSectionTitleField} - {isExpenseReport && shouldShowFurtherDetailsContent && nameSectionFurtherDetailsContent} - - {!isExpenseReport && nameSectionGroupWorkspace} - - {shouldShowReportDescription && ( - - - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_DESCRIPTION.path))} - /> - - - )} + + + + {isExpenseReport ? : } + + {isFinancialReportsForBusinesses && ( <> @@ -1121,10 +836,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report )} - + {menuItems.map((item) => ( ; + translationKey: TranslationPaths; + icon: IconAsset; + isAnonymousAction: boolean; + action: () => void; + brickRoadIndicator?: ValueOf; + subtitle?: number; + shouldShowRightIcon?: boolean; + subtitleStyle?: StyleProp; +}; + +const CASES = { + DEFAULT: 'default', + MONEY_REQUEST: 'money_request', + MONEY_REPORT: 'money_report', +}; + +type CaseID = ValueOf; + +export {CASES}; +export type {CaseID, DynamicReportDetailsPageMenuItem}; diff --git a/src/pages/DynamicReportDetailsPage/useReportDetailsReportName.ts b/src/pages/DynamicReportDetailsPage/useReportDetailsReportName.ts new file mode 100644 index 000000000000..504ff25b2fce --- /dev/null +++ b/src/pages/DynamicReportDetailsPage/useReportDetailsReportName.ts @@ -0,0 +1,31 @@ +import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; + +import Parser from '@libs/Parser'; +import {getReportNameFromNames} from '@libs/ReportAttributesUtils'; +import {getReportName} from '@libs/ReportNameUtils'; +import {getReportForHeader, isGroupChat} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import type {Report, ReportAction} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +/** + * Resolves the name shown on the details page and the derived name of the parent report from the REPORT_ATTRIBUTES + * derived value. Owning the derived-value subscription here means only the name and title rows re-render when report + * names change. + */ +function useReportDetailsReportName(report: OnyxEntry, parentReport: OnyxEntry, parentReportAction: OnyxEntry) { + const reportForHeader = getReportForHeader(report, parentReport); + const derivedReportNames = useDerivedReportNamesByReportIDs([report?.parentReportID, reportForHeader?.reportID]); + const derivedParentReportName = getReportNameFromNames(derivedReportNames, report?.parentReportID); + const derivedHeaderReportName = getReportNameFromNames(derivedReportNames, reportForHeader?.reportID); + + const shouldParseFullTitle = parentReportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && !isGroupChat(report); + const rawReportName = getReportName(reportForHeader, derivedHeaderReportName); + const reportName = shouldParseFullTitle ? Parser.htmlToText(rawReportName) : rawReportName; + + return {reportName, derivedParentReportName}; +} + +export default useReportDetailsReportName; diff --git a/src/pages/DynamicRoomMemberDetailsPage.tsx b/src/pages/DynamicRoomMemberDetailsPage.tsx index ca69466a2041..bb0261e09fd0 100644 --- a/src/pages/DynamicRoomMemberDetailsPage.tsx +++ b/src/pages/DynamicRoomMemberDetailsPage.tsx @@ -21,7 +21,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {RoomMembersNavigatorParamList} from '@libs/Navigation/types'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import {isPolicyAdmin} from '@libs/PolicyUtils'; +import {isRoomMemberProtectedByPolicyRole} from '@libs/PolicyUtils'; import {isPolicyExpenseChat} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; @@ -59,7 +59,10 @@ function DynamicRoomMemberDetailsPage({report, route}: DynamicRoomMemberDetailsP const displayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: details, translate, formatPhoneNumber}); const isSelectedMemberCurrentUser = accountID === currentUserPersonalDetails?.accountID; const isSelectedMemberOwner = accountID === report.ownerAccountID; - const shouldDisableRemoveUser = (isPolicyExpenseChat(report) && isPolicyAdmin(policy, details.login)) || isSelectedMemberCurrentUser || isSelectedMemberOwner; + // Check the selected member's own role on the policy, not the viewer's, and fail closed when their login is missing. + // Kept in sync with the members list through the shared helper. + const isSelectedMemberProtectedByPolicyRole = isRoomMemberProtectedByPolicyRole(policy, details.login, accountID); + const shouldDisableRemoveUser = (isPolicyExpenseChat(report) && isSelectedMemberProtectedByPolicyRole) || isSelectedMemberCurrentUser || isSelectedMemberOwner; const askForConfirmationToRemove = () => { showConfirmModal({ buttonVariant: CONST.BUTTON_VARIANT.DANGER, diff --git a/src/pages/DynamicRoomMembersPage.tsx b/src/pages/DynamicRoomMembersPage.tsx index 209bc8da2ced..c4f428bbf5e0 100644 --- a/src/pages/DynamicRoomMembersPage.tsx +++ b/src/pages/DynamicRoomMembersPage.tsx @@ -19,6 +19,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -34,7 +35,7 @@ import type {RoomMembersNavigatorParamList} from '@libs/Navigation/types'; import {isPersonalDetailsReady} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import {isPolicyAdmin, isPolicyEmployee as isPolicyEmployeeUtils} from '@libs/PolicyUtils'; +import {isPolicyEmployee as isPolicyEmployeeUtils, isRoomMemberProtectedByPolicyRole} from '@libs/PolicyUtils'; import {getReportAction} from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; import { @@ -54,7 +55,6 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {personalDetailsSelector} from '@src/selectors/PersonalDetails'; import type {PersonalDetails} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -114,8 +114,7 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { const [selectedMembers, setSelectedMembers] = useFilteredSelection(personalDetailsParticipants, shouldIncludeMember); const firstSelectedMember = selectedMembers?.at(0); - const firstSelectedMemberSelector = useMemo(() => personalDetailsSelector(firstSelectedMember), [firstSelectedMember]); - const [firstSelectedMemberDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: firstSelectedMemberSelector}); + const [firstSelectedMemberDetails] = usePersonalDetail(firstSelectedMember); const clearTableSelection = useCallback(() => { setSelectedMembers((prevSelectedMembers) => (prevSelectedMembers.length > 0 ? [] : prevSelectedMembers)); @@ -214,10 +213,12 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { continue; } const pendingChatMember = reportMetadata?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString()); - const isAdmin = isPolicyAdmin(policy, details.login); + // Check the listed member's own role on the policy, not the viewer's, and fail closed when their login is + // missing. Kept in sync with the member details page through the shared helper. + const isProtectedByPolicyRole = isRoomMemberProtectedByPolicyRole(policy, details.login, accountID); const isDisabled = pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || details.isOptimisticPersonalDetail; const isSelectionDisabled = - (isPolicyExpenseChat && isAdmin) || + (isPolicyExpenseChat && isProtectedByPolicyRole) || accountID === session?.accountID || pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || details.accountID === report.ownerAccountID; diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index 943dfa433a3e..ee11a1be7697 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -9,14 +9,19 @@ import ScrollView from '@components/ScrollView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useMultipleSnapshots from '@hooks/useMultipleSnapshots'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getInsights} from '@libs/actions/Insights'; +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {InsightsDashboardID} from '@src/types/onyx'; @@ -25,7 +30,7 @@ import React, {useEffect, useEffectEvent} from 'react'; import {View} from 'react-native'; import type {InsightsFilters} from './insightsFilters'; -import type {InsightsDashboardState} from './resolveDashboardState'; +import type {InsightsDashboardChart, InsightsDashboardState} from './resolveDashboardState'; import InsightsChartWidget from './charts/InsightsChartWidget'; import InsightsPageControls from './controls/InsightsPageControls'; @@ -37,12 +42,11 @@ import InsightsNoExpensesState from './states/InsightsNoExpensesState'; import useInsightsFilters from './useInsightsFilters'; type InsightsDashboardContentProps = { - dashboardID: InsightsDashboardID; - - /** Hash of the dashboard-wide query, which the record every chart reads is stored under */ - hash: number | undefined; - state: InsightsDashboardState; + headlineChart: InsightsDashboardChart; + + /** Charts in the grid below the headline, left out when no workspace in scope is eligible for them */ + supportingCharts: InsightsDashboardChart[]; /** Page-level filters every chart on the dashboard is narrowed by */ filters: InsightsFilters; @@ -54,23 +58,36 @@ type InsightsDashboardContentProps = { onGroupByChange: (groupBy: InsightsFilters['groupBy']) => void; }; -function InsightsDashboardContent({dashboardID, hash, state, filters, onRetry, onGroupByChange}: InsightsDashboardContentProps) { +function InsightsDashboardContent({state, headlineChart, supportingCharts, filters, onRetry, onGroupByChange}: InsightsDashboardContentProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {login} = useCurrentUserPersonalDetails(); const icons = useMemoizedLazyExpensifyIcons(['OfflineCloud']); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - if (state === INSIGHTS_DASHBOARD_STATE.ERROR) { + const didRequestFail = state === INSIGHTS_DASHBOARD_STATE.ERROR || state === INSIGHTS_DASHBOARD_STATE.STALE; + + if (didRequestFail) { + const failureViewByState = { + [INSIGHTS_DASHBOARD_STATE.STALE]: { + title: translate('search.searchResults.staleResults.title'), + subtitle: translate('search.searchResults.staleResults.subtitle'), + illustration: 'ChartSync', + illustrationWidth: variables.iconSizeUltraLarge, + illustrationHeight: variables.iconSizeUltraLarge, + buttonTranslationKey: 'search.searchResults.staleResults.buttonText', + }, + [INSIGHTS_DASHBOARD_STATE.ERROR]: { + title: translate('errorPage.title', {isBreakLine: shouldUseNarrowLayout}), + subtitle: translate('errorPage.subtitle'), + buttonTranslationKey: 'common.tryAgain', + }, + } as const; return ( ); } @@ -99,22 +116,20 @@ function InsightsDashboardContent({dashboardID, hash, state, filters, onRetry, o ); } - const {headlineChart, supportingCharts} = INSIGHTS_DASHBOARD_SPECS[dashboardID]; - const visibleCharts = getVisibleCharts(supportingCharts, policies, filters.policyIDs, login); - // Wide layout stacks the cards in two independent columns, so a short card doesn't leave a gap under it - const columns = shouldUseNarrowLayout ? [visibleCharts] : [visibleCharts.filter((chart, index) => index % 2 === 0), visibleCharts.filter((chart, index) => index % 2 === 1)]; + const columns = shouldUseNarrowLayout ? [supportingCharts] : [supportingCharts.filter((chart, index) => index % 2 === 0), supportingCharts.filter((chart, index) => index % 2 === 1)]; return ( - {columnCharts.map((chart) => ( + {columnCharts.map(({chart, queryJSON, snapshot}) => ( @@ -148,9 +163,12 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); + const {login} = useCurrentUserPersonalDetails(); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {filters, defaultFilters, isResolved, setFilters} = useInsightsFilters(dashboardID); + const {isBetaEnabled} = usePermissions(); - const query = isResolved ? buildInsightsJsonQuery(dashboardID, filters) : undefined; + const query = isResolved ? buildInsightsJsonQuery(dashboardID, filters, isBetaEnabled(CONST.BETAS.INSIGHTS_COMPARE)) : undefined; const jsonQuery = query?.jsonQuery; const hash = query?.hash; @@ -173,8 +191,19 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { }, [dashboardID, jsonQuery, hash, isFocused, isOffline]); const [dashboard] = useOnyx(`${ONYXKEYS.COLLECTION.INSIGHTS}${dashboardID}_${hash}`); - const [headlineSnapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${dashboard?.graphs?.[INSIGHTS_DASHBOARD_SPECS[dashboardID].headlineChart.graphKey]?.snapshotHash}`); - const state = getDashboardState(dashboard, isOffline, headlineSnapshot); + const {headlineChart: headlineSpec, supportingCharts: supportingSpecs} = INSIGHTS_DASHBOARD_SPECS[dashboardID]; + const eligibleCharts = getVisibleCharts(supportingSpecs, policies, filters.policyIDs, login); + // Until the filters resolve, the charts have no query to read data for, so they show their loading state + const visibleChartQueries = query + ? [query.headlineChart, ...query.supportingCharts.filter(({chart}) => eligibleCharts.includes(chart))] + : [headlineSpec, ...eligibleCharts].map((chart) => ({chart, queryJSON: undefined})); + const snapshots = useMultipleSnapshots(visibleChartQueries.flatMap(({queryJSON}) => (queryJSON ? [String(queryJSON.hash)] : []))); + const charts: InsightsDashboardChart[] = visibleChartQueries.map((chartQuery) => ({ + ...chartQuery, + snapshot: chartQuery.queryJSON ? snapshots[chartQuery.queryJSON.hash] : undefined, + })); + const [headlineChart, ...supportingCharts] = charts; + const state = getDashboardState(dashboard, isOffline, charts); return ( )} setFilters({groupBy})} diff --git a/src/pages/Insights/charts/InsightsChartWidget.tsx b/src/pages/Insights/charts/InsightsChartWidget.tsx index 5cfbbb54b906..31012cb23eab 100644 --- a/src/pages/Insights/charts/InsightsChartWidget.tsx +++ b/src/pages/Insights/charts/InsightsChartWidget.tsx @@ -1,26 +1,31 @@ import {buildViewOnSpendQuery} from '@components/Search/chartDrillDown'; import ChartEmptyState from '@components/Search/ChartEmptyState'; import ChartErrorState from '@components/Search/ChartErrorState'; +import ChartOfflineState from '@components/Search/ChartOfflineState'; +import useGroupedItems from '@components/Search/hooks/useGroupedItems'; import SearchChartView from '@components/Search/SearchChartView'; +import type {SearchQueryJSON} from '@components/Search/types'; import WidgetContainer from '@components/WidgetContainer'; import WidgetHeaderMenu from '@components/WidgetHeaderMenu'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import {INSIGHTS_CHART_STATE, resolveInsightsChartData} from '@libs/resolveInsightsChartData'; import InsightsGroupByDropdown from '@pages/Insights/controls/InsightsGroupByDropdown'; import type {InsightsChartSpec} from '@pages/Insights/dashboardSpecs'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; -import {INSIGHTS_CHART_STATE} from '@pages/Insights/resolveChartData'; -import useInsightsChartData from '@pages/Insights/useInsightsChartData'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import type {InsightsDashboardID} from '@src/types/onyx'; +import type {SearchResults} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; import {View} from 'react-native'; @@ -28,16 +33,17 @@ import {View} from 'react-native'; import InsightsDataTable from './InsightsDataTable'; type InsightsChartWidgetProps = { - dashboardID: InsightsDashboardID; - chart: InsightsChartSpec; + /** The chart's own query, with the page's filters applied */ + queryJSON: Readonly | undefined; + + /** The snapshot stored under the chart's own query */ + snapshot: OnyxEntry; + /** Page-level filters every chart on the dashboard is narrowed by */ filters: InsightsFilters; - /** Hash of the dashboard-wide query, which the record naming this chart's snapshot is stored under */ - hash: number | undefined; - /** Called by the retry button to request the dashboard again */ onRetry: () => void; @@ -45,21 +51,19 @@ type InsightsChartWidgetProps = { onGroupByChange?: (groupBy: InsightsFilters['groupBy']) => void; }; -function InsightsChartWidget({dashboardID, hash, chart, filters, onRetry, onGroupByChange}: InsightsChartWidgetProps) { +function InsightsChartWidget({chart, queryJSON, snapshot, filters, onRetry, onGroupByChange}: InsightsChartWidgetProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['Expand']); - const {queryJSON, data, state} = useInsightsChartData(dashboardID, hash, chart, filters); - const groupBy = queryJSON?.groupBy; + const {isOffline} = useNetwork(); + const sortedData = useGroupedItems(snapshot, queryJSON); + const {data, state} = resolveInsightsChartData({snapshot, queryJSON, sortedData, isOffline}); + const groupBy = chart.groupBy ?? filters.groupBy; const isLoading = state === INSIGHTS_CHART_STATE.LOADING; const shouldShowTable = chart.view === CONST.SEARCH.VIEW.BAR || chart.view === CONST.SEARCH.VIEW.PIE; - if (!queryJSON || !groupBy) { - return null; - } - const groupByControl = onGroupByChange ? ( + {state === INSIGHTS_CHART_STATE.OFFLINE && } {state === INSIGHTS_CHART_STATE.ERROR && } {state === INSIGHTS_CHART_STATE.EMPTY && } {(state === INSIGHTS_CHART_STATE.LOADING || state === INSIGHTS_CHART_STATE.READY) && ( diff --git a/src/pages/Insights/charts/InsightsDataTable.tsx b/src/pages/Insights/charts/InsightsDataTable.tsx index 6520e6f5bdb2..f89b54d27b5f 100644 --- a/src/pages/Insights/charts/InsightsDataTable.tsx +++ b/src/pages/Insights/charts/InsightsDataTable.tsx @@ -2,6 +2,7 @@ import UserAvatar from '@components/Avatar/UserAvatar'; import type {TransactionCardGroupListItemType, TransactionMemberGroupListItemType} from '@components/Search/SearchList/ListItem/types'; import type {ChartView, GroupedItem, SearchChartDataRow, SearchGroupBy} from '@components/Search/types'; import Text from '@components/Text'; +import TextWithTooltip from '@components/TextWithTooltip'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; @@ -87,13 +88,15 @@ function InsightsDataTable({rows, view, groupBy, isLoading}: InsightsDataTablePr /> )} - {point.label} - + - {translate('iou.expenseCount', {count: item.count})} - + shouldShowTooltip + /> {convertToDisplayString(item.total ?? 0, item.currency)} diff --git a/src/pages/Insights/controls/InsightsCompareControl.tsx b/src/pages/Insights/controls/InsightsCompareControl.tsx new file mode 100644 index 000000000000..a1f90596ec26 --- /dev/null +++ b/src/pages/Insights/controls/InsightsCompareControl.tsx @@ -0,0 +1,47 @@ +import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; +import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/FilterPopupButton'; +import SingleSelectPopup from '@components/Search/FilterDropdowns/SingleSelectPopup'; +import type {SearchCompareMode} from '@components/Search/types'; + +import useLocalize from '@hooks/useLocalize'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +import type {InsightsControlProps} from './insightsControls'; + +import INSIGHTS_CONTROL_ANCHOR_ALIGNMENT from './insightsControls'; + +function InsightsCompareControl({value, onChange}: InsightsControlProps) { + const {translate} = useLocalize(); + const label = translate('insightsPage.compare.label'); + + const items = [ + {text: translate('insightsPage.compare.previousPeriod'), value: CONST.SEARCH.COMPARE.PREVIOUS_PERIOD}, + {text: translate('insightsPage.compare.average'), value: CONST.SEARCH.COMPARE.AVERAGE}, + ]; + const selectedItem = items.find((item) => item.value === value); + + const comparePopover = ({closeOverlay}: PopoverComponentProps) => ( + onChange(item?.value)} + /> + ); + + return ( + + ); +} + +export default InsightsCompareControl; diff --git a/src/pages/Insights/controls/InsightsPageControls.tsx b/src/pages/Insights/controls/InsightsPageControls.tsx index 92d8877d854b..74686c574e40 100644 --- a/src/pages/Insights/controls/InsightsPageControls.tsx +++ b/src/pages/Insights/controls/InsightsPageControls.tsx @@ -1,13 +1,17 @@ import ScrollView from '@components/ScrollView'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import CONST from '@src/CONST'; + import React from 'react'; import {View} from 'react-native'; +import InsightsCompareControl from './InsightsCompareControl'; import InsightsDateControl from './InsightsDateControl'; import InsightsGroupCurrencyControl from './InsightsGroupCurrencyControl'; import InsightsWorkspaceControl from './InsightsWorkspaceControl'; @@ -25,6 +29,7 @@ type InsightsPageControlsProps = { function InsightsPageControls({filters, defaultFilters, onChange}: InsightsPageControlsProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); const controls = ( <> @@ -32,6 +37,12 @@ function InsightsPageControls({filters, defaultFilters, onChange}: InsightsPageC value={filters.date} onChange={(date) => onChange({date})} /> + {isBetaEnabled(CONST.BETAS.INSIGHTS_COMPARE) && ( + onChange({compare})} + /> + )} onChange({policyIDs})} @@ -50,7 +61,7 @@ function InsightsPageControls({filters, defaultFilters, onChange}: InsightsPageC horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" - style={[styles.flexGrow0, styles.flexShrink0, styles.pb3]} + style={[styles.flexGrow0, styles.flexShrink0, styles.pb5]} contentContainerStyle={[styles.flexRow, styles.alignItemsCenter, styles.gap2, styles.ph5]} > {controls} @@ -58,7 +69,11 @@ function InsightsPageControls({filters, defaultFilters, onChange}: InsightsPageC ); } - return {controls}; + return ( + + {controls} + + ); } export default InsightsPageControls; diff --git a/src/pages/Insights/dashboardSpecs.ts b/src/pages/Insights/dashboardSpecs.ts index 78f8022f1827..baf4a6a7d7cc 100644 --- a/src/pages/Insights/dashboardSpecs.ts +++ b/src/pages/Insights/dashboardSpecs.ts @@ -13,7 +13,7 @@ import type {InsightsDashboardID, InsightsGraphKey, InsightsSearchKey, Policy} f import type {OnyxCollection} from 'react-native-onyx'; type InsightsChartSpec = { - /** Slot the chart finds its snapshot hash under in the stored dashboard's `graphs` */ + /** Slot the request names the chart's snapshot under, and the response's `graphs` confirms it in */ graphKey: InsightsGraphKey; titleKey: TranslationPaths; view: ChartView; diff --git a/src/pages/Insights/insightsFilterParsing.ts b/src/pages/Insights/insightsFilterParsing.ts index 72a078da2ae1..924bb47b34aa 100644 --- a/src/pages/Insights/insightsFilterParsing.ts +++ b/src/pages/Insights/insightsFilterParsing.ts @@ -42,6 +42,10 @@ function parseGroupBy(queryJSON: SearchQueryJSON): Pick | undefined { + return queryJSON.compare ? {compare: queryJSON.compare} : undefined; +} + /** Reads a dashboard's stored selections. Anything the query doesn't carry is left out, so the caller's defaults show through. */ function parseInsightsFilters(query: string | undefined): Partial { const queryJSON = query ? buildSearchQueryJSON(query) : undefined; @@ -54,6 +58,7 @@ function parseInsightsFilters(query: string | undefined): Partial = { @@ -22,7 +30,7 @@ const DEFAULT_INSIGHTS_FILTERS: Omit = { groupBy: CONST.SEARCH.GROUP_BY.MONTH, }; -const INSIGHTS_GROUP_BY_OPTIONS = [CONST.SEARCH.GROUP_BY.WEEK, CONST.SEARCH.GROUP_BY.MONTH, CONST.SEARCH.GROUP_BY.QUARTER, CONST.SEARCH.GROUP_BY.YEAR] as const; +const INSIGHTS_GROUP_BY_OPTIONS = [CONST.SEARCH.GROUP_BY.DAY, CONST.SEARCH.GROUP_BY.WEEK, CONST.SEARCH.GROUP_BY.MONTH, CONST.SEARCH.GROUP_BY.QUARTER, CONST.SEARCH.GROUP_BY.YEAR] as const; export type {InsightsFilters}; export {INSIGHTS_GROUP_BY_OPTIONS}; diff --git a/src/pages/Insights/insightsQueries.ts b/src/pages/Insights/insightsQueries.ts index a1fbecf19597..fe61577ac23b 100644 --- a/src/pages/Insights/insightsQueries.ts +++ b/src/pages/Insights/insightsQueries.ts @@ -1,7 +1,8 @@ -import type {SearchQueryString} from '@components/Search/types'; +import type {SearchCompareMode, SearchQueryJSON, SearchQueryString} from '@components/Search/types'; import {buildQueryStringFromFilterFormValues, buildSearchQueryJSON, getRangeQueryValue} from '@libs/SearchQueryUtils'; +import CONST from '@src/CONST'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {InsightsDashboard, InsightsDashboardID, InsightsGraphKey} from '@src/types/onyx'; @@ -10,6 +11,9 @@ import type {InsightsFilters} from './insightsFilters'; import INSIGHTS_DASHBOARD_SPECS from './dashboardSpecs'; +/** How many periods before the selected date range the Average mode spans (Typical on the ranking charts) */ +const COMPARE_TYPICAL_PERIOD_COUNT = 1; + /** Builds the date filter in the shape a search query is built from. */ function buildDateFormValues(date: InsightsFilters['date']): Partial { if ('preset' in date) { @@ -32,44 +36,77 @@ function buildFilterFormValues(filters: InsightsFilters): Partial { - const snapshotHash = buildSearchQueryJSON(applyInsightsFilters(chart, filters))?.hash; - return snapshotHash ? [[chart.graphKey, {snapshotHash}]] : []; +type InsightsChartQuery = { + chart: InsightsChartSpec; + queryJSON: Readonly | undefined; +}; + +type InsightsGraphHashes = { + snapshotHash: number; + previousPeriodSnapshotHash?: number; + averageSnapshotHash?: number; +}; +type InsightsGraphHashEntry = [InsightsGraphKey, InsightsGraphHashes]; + +/** Returns the chart's graph slot paired with the hashes its current period, previous period and average are stored under. */ +function buildSnapshotHashEntry({chart, queryJSON}: InsightsChartQuery, filters: InsightsFilters, shouldIncludeComparisons: boolean): InsightsGraphHashEntry | undefined { + const snapshotHash = queryJSON?.hash; + if (!snapshotHash) { + return undefined; + } + if (!shouldIncludeComparisons) { + return [chart.graphKey, {snapshotHash}]; + } + + const previousPeriodSnapshotHash = buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.PREVIOUS_PERIOD))?.hash; + const averageSnapshotHash = buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.AVERAGE))?.hash; + if (!previousPeriodSnapshotHash || !averageSnapshotHash) { + return undefined; + } + + return [chart.graphKey, {snapshotHash, previousPeriodSnapshotHash, averageSnapshotHash}]; } type InsightsQuery = { /** Request payload for GetInsights. */ jsonQuery: string; - /** Hash of the dashboard-wide query, which the response is stored under so every set of filters keeps its own dashboard entry. */ + /** Hash of the dashboard-wide query, which the response is stored under so every set of filters keeps its own dashboard entry. Leaves out the comparison, since every response carries all of them. */ hash: number; /** Hashes of the snapshots the graphs are stored under. */ snapshotHashes: number[]; + + headlineChart: InsightsChartQuery; + supportingCharts: InsightsChartQuery[]; }; -/** Builds one request for the whole dashboard: the shared filters query plus the snapshot hash each graph's data is stored under. */ -function buildInsightsJsonQuery(dashboard: InsightsDashboardID, filters: InsightsFilters): InsightsQuery | undefined { - const inputQuery = buildInsightsQueryString(filters); +/** Builds one request for the whole dashboard, naming each graph's snapshots by its chart's query hashes, and returns those chart queries to read the snapshots back with. */ +function buildInsightsJsonQuery(dashboard: InsightsDashboardID, filters: InsightsFilters, shouldIncludeComparisons: boolean): InsightsQuery | undefined { + const inputQuery = buildInsightsQueryString({...filters, compare: undefined}); const queryJSON = buildSearchQueryJSON(inputQuery); if (!queryJSON) { return undefined; } - const {searchKey, headlineChart, supportingCharts} = INSIGHTS_DASHBOARD_SPECS[dashboard]; - const graphEntries = [...buildSnapshotHashEntries(headlineChart, filters), ...supportingCharts.flatMap((chart) => buildSnapshotHashEntries(chart, filters))]; + const spec = INSIGHTS_DASHBOARD_SPECS[dashboard]; + const buildChartQuery = (chart: InsightsChartSpec): InsightsChartQuery => ({chart, queryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters))}); + const headlineChart = buildChartQuery(spec.headlineChart); + const supportingCharts = spec.supportingCharts.map(buildChartQuery); + const graphEntries = [headlineChart, ...supportingCharts] + .map((chartQuery) => buildSnapshotHashEntry(chartQuery, filters, shouldIncludeComparisons)) + .filter((entry): entry is InsightsGraphHashEntry => !!entry); const insightsHashes: InsightsDashboard['graphs'] = Object.fromEntries(graphEntries); return { @@ -78,14 +115,17 @@ function buildInsightsJsonQuery(dashboard: InsightsDashboardID, filters: Insight groupBy: queryJSON.groupBy, filters: queryJSON.filters, inputQuery, - searchKey, + searchKey: spec.searchKey, insightsHashes, + numberOfPeriods: shouldIncludeComparisons ? COMPARE_TYPICAL_PERIOD_COUNT : undefined, }), hash: queryJSON.hash, - snapshotHashes: graphEntries.map(([, {snapshotHash}]) => snapshotHash), + snapshotHashes: graphEntries.flatMap(([, hashes]) => Object.values(hashes).filter((hash): hash is number => hash !== undefined)), + headlineChart, + supportingCharts, }; } export {applyInsightsFilters, buildDateFormValues, buildInsightsQueryString}; -export type {InsightsQuery}; +export type {InsightsChartQuery, InsightsQuery}; export default buildInsightsJsonQuery; diff --git a/src/pages/Insights/resolveDashboardState.ts b/src/pages/Insights/resolveDashboardState.ts index dd94be91b91d..65be897e50c9 100644 --- a/src/pages/Insights/resolveDashboardState.ts +++ b/src/pages/Insights/resolveDashboardState.ts @@ -1,15 +1,20 @@ -import {isGroupEntry} from '@libs/SearchUIUtils'; +import {isGroupEntry, isSearchDataLoaded} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; import type {InsightsDashboard} from '@src/types/onyx'; import type SearchResults from '@src/types/onyx/SearchResults'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import type {InsightsChartQuery} from './insightsQueries'; + const INSIGHTS_DASHBOARD_STATE = { READY: 'ready', LOADING: 'loading', ERROR: 'error', + STALE: 'stale', OFFLINE: 'offline', EMPTY: 'empty', NO_EXPENSES: 'noExpenses', @@ -17,28 +22,40 @@ const INSIGHTS_DASHBOARD_STATE = { type InsightsDashboardState = ValueOf; -/** Resolves the page's state from the record stored for the query on screen, which the key it is read under already scopes. */ -function getDashboardState(dashboard: OnyxEntry, isOffline: boolean, headlineSnapshot: OnyxEntry): InsightsDashboardState { - // Only a response sets `inputQuery`, so until one lands the record holds nothing to draw. - const isDataLoaded = !!dashboard?.inputQuery; +type InsightsDashboardChart = InsightsChartQuery & { + snapshot: OnyxEntry; +}; + +/** Resolves the page's state from the dashboard record and the snapshot of every chart on screen. */ +function getDashboardState(dashboard: OnyxEntry, isOffline: boolean, charts: InsightsDashboardChart[]): InsightsDashboardState { + // Only a GetInsights response sets `inputQuery`. + const hasDashboardResponse = !!dashboard?.inputQuery; + const chartStates = charts.map(({snapshot, queryJSON}) => { + const isLoaded = isSearchDataLoaded(snapshot, queryJSON) && isEmptyObject(snapshot?.errors); + return {isLoaded, hasRows: isLoaded && Object.keys(snapshot?.data ?? {}).some(isGroupEntry)}; + }); + const hasLoadedChart = chartStates.some(({isLoaded}) => isLoaded); - if (isOffline && !isDataLoaded) { + if (isOffline && !hasLoadedChart && dashboard?.hasResults !== false) { return INSIGHTS_DASHBOARD_STATE.OFFLINE; } if (!isOffline && Object.keys(dashboard?.errors ?? {}).length > 0) { - return INSIGHTS_DASHBOARD_STATE.ERROR; + return dashboard?.responseJsonCode === CONST.JSON_CODE.NO_RESPONSE ? INSIGHTS_DASHBOARD_STATE.STALE : INSIGHTS_DASHBOARD_STATE.ERROR; } - if (!isDataLoaded) { + if (!hasDashboardResponse && !hasLoadedChart) { return INSIGHTS_DASHBOARD_STATE.LOADING; } + if (chartStates.some(({hasRows}) => hasRows)) { + return INSIGHTS_DASHBOARD_STATE.READY; + } if (dashboard?.hasResults === false) { return INSIGHTS_DASHBOARD_STATE.NO_EXPENSES; } - if (headlineSnapshot?.data && !Object.keys(headlineSnapshot.data).some(isGroupEntry)) { + if (chartStates.every(({isLoaded}) => isLoaded)) { return INSIGHTS_DASHBOARD_STATE.EMPTY; } return INSIGHTS_DASHBOARD_STATE.READY; } export {INSIGHTS_DASHBOARD_STATE, getDashboardState}; -export type {InsightsDashboardState}; +export type {InsightsDashboardState, InsightsDashboardChart}; diff --git a/src/pages/Insights/useInsightsChartData.ts b/src/pages/Insights/useInsightsChartData.ts deleted file mode 100644 index bdfd2016a323..000000000000 --- a/src/pages/Insights/useInsightsChartData.ts +++ /dev/null @@ -1,33 +0,0 @@ -import useGroupedItems from '@components/Search/hooks/useGroupedItems'; -import type {SearchQueryJSON} from '@components/Search/types'; - -import useOnyx from '@hooks/useOnyx'; - -import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; - -import ONYXKEYS from '@src/ONYXKEYS'; -import type {InsightsDashboardID} from '@src/types/onyx'; - -import type {InsightsChartSpec} from './dashboardSpecs'; -import type {InsightsFilters} from './insightsFilters'; -import type {InsightsChartData} from './resolveChartData'; - -import {applyInsightsFilters} from './insightsQueries'; -import {resolveInsightsChartData} from './resolveChartData'; - -/** Resolves one chart's data from the snapshot the dashboard record names for it. */ -function useInsightsChartData( - dashboardID: InsightsDashboardID, - hash: number | undefined, - chart: InsightsChartSpec, - filters: InsightsFilters, -): InsightsChartData & {queryJSON: Readonly | undefined} { - const queryJSON = buildSearchQueryJSON(applyInsightsFilters(chart, filters)); - const [dashboard] = useOnyx(`${ONYXKEYS.COLLECTION.INSIGHTS}${dashboardID}_${hash}`); - const [snapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${dashboard?.graphs?.[chart.graphKey]?.snapshotHash}`); - const sortedData = useGroupedItems(snapshot, queryJSON); - - return {queryJSON, ...resolveInsightsChartData({chart, dashboard, snapshot, sortedData})}; -} - -export default useInsightsChartData; diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx index c37ac14866f9..3a7d93beffc8 100644 --- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx +++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx @@ -26,12 +26,14 @@ type LogInWithShortLivedAuthTokenPageProps = PlatformStackScreenProps { - // Only a forced SAML re-auth keeps a last visited path, so it has to be read before the sign-in starts. - if (isLoadingLastVisitedPath) { + // Only a forced SAML re-auth keeps a last visited path, so it has to be read (along with the credentials + // the sign-in compares against) before the sign-in starts. + if (isLoadingSignInData) { return; } @@ -57,7 +59,7 @@ function LogInWithShortLivedAuthTokenPage({route}: LogInWithShortLivedAuthTokenP // A forced SAML re-auth leaves account.isLoading true until this sign-in, so it must not block a SAML token. if (token && (isSAML || !account?.isLoading)) { Log.info('LogInWithShortLivedAuthTokenPage - Successfully received shortLivedAuthToken. Signing in...'); - signInWithShortLivedAuthToken(token, isSAML, isSAML ? lastVisitedPath : undefined); + signInWithShortLivedAuthToken(token, isSAML, isSAML ? lastVisitedPath : undefined, credentials?.login); // For SAML sign-ins, navigate to HOME explicitly since the SAML flow // doesn't use exitTo deep link routing. For non-SAML flows, let the // navigation system handle exitTo routing naturally via setUpPoliciesAndNavigate. @@ -82,11 +84,11 @@ function LogInWithShortLivedAuthTokenPage({route}: LogInWithShortLivedAuthTokenP Navigation.navigate(exitTo as Route); }); } - // Runs once the route and the last visited path are known, later Onyx changes must not restart the sign-in. + // Runs once the route, the credentials and the last visited path are known, later Onyx changes must not restart the sign-in. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [route, isLoadingLastVisitedPath]); + }, [route, isLoadingSignInData]); - if (account?.isLoading || isLoadingLastVisitedPath) { + if (account?.isLoading || isLoadingSignInData) { return ; } diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx index 9ac0b47ce8ca..0bf187abac7b 100644 --- a/src/pages/LogOutPreviousUserPage.tsx +++ b/src/pages/LogOutPreviousUserPage.tsx @@ -74,7 +74,7 @@ function LogOutPreviousUserPage({route}: LogOutPreviousUserPageProps) { // Even if the user was already authenticated in NewDot, we need to reauthenticate them with shortLivedAuthToken, // because the old authToken stored in Onyx may be invalid. - signInWithShortLivedAuthToken(shortLivedAuthToken); + signInWithShortLivedAuthToken(shortLivedAuthToken, false, undefined, undefined); // We only want to run this effect once on mount (when the page first loads after transitioning from OldDot) // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/pages/NewChatPage/index.tsx b/src/pages/NewChatPage/index.tsx index dbf931272150..ab68345bd1ee 100755 --- a/src/pages/NewChatPage/index.tsx +++ b/src/pages/NewChatPage/index.tsx @@ -174,7 +174,7 @@ function NewChatPage({ref}: NewChatPageProps) { !!userToInvite, debouncedSearchTerm.trim(), countryCode, - selectedOptions.some((participant) => doesPersonalDetailMatchSearchTerm(participant, currentUserAccountID, cleanSearchTerm)), + selectedOptions.some((participant) => doesPersonalDetailMatchSearchTerm(participant, currentUserAccountID, cleanSearchTerm, translate)), ); // Selected rows are marked in place by the hook (isSelected), so the checkmark stays with the row instead of jumping to the top. @@ -189,7 +189,9 @@ function NewChatPage({ref}: NewChatPageProps) { // to keep them visible and easy to deselect. The one already shown as the current invite row is excluded to avoid a duplicate. const selectedSection = selectedOptions.filter( (option) => - !!option.isOptimisticAccount && !(userToInvite && option.login === userToInvite.login) && doesPersonalDetailMatchSearchTerm(option, currentUserAccountID, cleanSearchTerm), + !!option.isOptimisticAccount && + !(userToInvite && option.login === userToInvite.login) && + doesPersonalDetailMatchSearchTerm(option, currentUserAccountID, cleanSearchTerm, translate), ); if (selectedSection.length) { diff --git a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx index c6b8a5fbb0ce..f64d8ba35e82 100644 --- a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx +++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx @@ -175,18 +175,16 @@ function BaseOnboardingAccounting({shouldUseNativeStyles}: BaseOnboardingAccount // Keep the selected fill on hover. hoverStyle={isSelected ? undefined : styles.hoveredComponentBG} style={[ - styles.onboardingAccountingItem, - isSmallScreenWidth ? styles.onboardingAccountingItemNarrow : styles.onboardingAccountingItemWide, - isSelected && styles.onboardingAccountingItemSelected, + styles.onboardingTile, + isSmallScreenWidth && !isInLandscapeMode ? styles.onboardingTileNarrow : styles.onboardingAccountingItemWide, + isSelected && styles.onboardingTileSelected, ]} > - {/* Square to match the mocks, but a radio because only one option can be picked. */} handleIntegrationSelect(optionKey)} accessibilityLabel={label} - containerBorderRadius={variables.componentBorderRadiusSmall} - wrapperStyle={styles.onboardingAccountingItemSelectionButton} + wrapperStyle={styles.onboardingTileSelectionButton} /> { if (!selectedCompanySize) { @@ -170,7 +171,6 @@ function BaseOnboardingEmployees({shouldUseNativeStyles, route}: BaseOnboardingE shouldUpdateFocusedIndex ListItem={SingleSelectListItem} footerContent={footerContent} - style={{listItemWrapperStyle: onboardingIsMediumOrLargerScreenWidth ? [styles.pl8, styles.pr8] : []}} /> ); diff --git a/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx b/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx index ff155f566d65..6a2dd31c3122 100644 --- a/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx +++ b/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx @@ -29,6 +29,8 @@ import type {OnboardingFeatureMapItem} from '@libs/actions/Welcome/OnboardingFea import Navigation from '@libs/Navigation/Navigation'; import {isGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -46,7 +48,7 @@ function BaseOnboardingInterestedFeatures({shouldUseNativeStyles}: BaseOnboardin // We need to use isSmallScreenWidth, see navigateAfterOnboarding function comment // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); + const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID); const [onboardingInterestedFeaturesMap] = useOnyx(ONYXKEYS.ONBOARDING_INTERESTED_FEATURES_MAP); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -60,27 +62,82 @@ function BaseOnboardingInterestedFeatures({shouldUseNativeStyles}: BaseOnboardin return ONBOARDING_FEATURES.map((feature) => { switch (feature.id) { case CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED: - return {...feature, title: translate('workspace.moreFeatures.categories.title'), icon: illustrations.FolderOpen}; + return { + ...feature, + title: translate('workspace.moreFeatures.categories.title'), + subtitle: translate('workspace.moreFeatures.categories.subtitle'), + icon: illustrations.FolderOpen, + }; case CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED: - return {...feature, title: translate('workspace.moreFeatures.connections.title'), icon: illustrations.Accounting}; + return { + ...feature, + title: translate('workspace.moreFeatures.connections.title'), + subtitle: translate('workspace.moreFeatures.connections.subtitle'), + icon: illustrations.Accounting, + }; case CONST.POLICY.MORE_FEATURES.ARE_COMPANY_CARDS_ENABLED: - return {...feature, title: translate('workspace.moreFeatures.companyCards.title'), icon: illustrations.CompanyCard}; + return { + ...feature, + title: translate('workspace.moreFeatures.companyCards.title'), + subtitle: translate('workspace.moreFeatures.companyCards.subtitle'), + icon: illustrations.CompanyCard, + }; case CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED: - return {...feature, title: translate('workspace.moreFeatures.workflows.title'), icon: illustrations.Workflows}; + return { + ...feature, + title: translate('workspace.moreFeatures.workflows.title'), + subtitle: translate('workspace.moreFeatures.workflows.subtitle'), + icon: illustrations.Workflows, + }; case CONST.POLICY.MORE_FEATURES.IS_TRAVEL_ENABLED: - return {...feature, title: translate('workspace.moreFeatures.travel.title'), icon: illustrations.Luggage}; + return { + ...feature, + title: translate('workspace.moreFeatures.travel.title'), + subtitle: translate('workspace.moreFeatures.travel.subtitle'), + icon: illustrations.Luggage, + }; case CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED: - return {...feature, title: translate('workspace.moreFeatures.rules.title'), icon: illustrations.Rules}; + return { + ...feature, + title: translate('workspace.moreFeatures.rules.title'), + subtitle: translate('workspace.moreFeatures.rules.subtitle'), + icon: illustrations.Rules, + }; case CONST.POLICY.MORE_FEATURES.ARE_DISTANCE_RATES_ENABLED: - return {...feature, title: translate('workspace.moreFeatures.distanceRates.title'), icon: illustrations.Car}; + return { + ...feature, + title: translate('workspace.moreFeatures.distanceRates.title'), + subtitle: translate('workspace.moreFeatures.distanceRates.subtitle'), + icon: illustrations.Car, + }; case CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED: - return {...feature, title: translate('workspace.moreFeatures.expensifyCard.title'), icon: illustrations.HandCard}; + return { + ...feature, + title: translate('workspace.moreFeatures.expensifyCard.title'), + subtitle: translate('workspace.moreFeatures.expensifyCard.subtitle'), + icon: illustrations.HandCard, + }; case CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED: - return {...feature, title: translate('workspace.moreFeatures.tags.title'), icon: illustrations.Tag}; + return { + ...feature, + title: translate('workspace.moreFeatures.tags.title'), + subtitle: translate('workspace.moreFeatures.tags.subtitle'), + icon: illustrations.Tag, + }; case CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED: - return {...feature, title: translate('workspace.moreFeatures.perDiem.title'), icon: illustrations.PerDiem}; + return { + ...feature, + title: translate('workspace.moreFeatures.perDiem.title'), + subtitle: translate('workspace.moreFeatures.perDiem.subtitle'), + icon: illustrations.PerDiem, + }; case CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED: - return {...feature, title: translate('workspace.moreFeatures.timeTracking.title'), icon: illustrations.Clock}; + return { + ...feature, + title: translate('workspace.moreFeatures.timeTracking.title'), + subtitle: translate('workspace.moreFeatures.timeTracking.subtitle'), + icon: illustrations.Clock, + }; default: return {...feature, title: feature.id, icon: illustrations.FolderOpen}; } @@ -139,11 +196,12 @@ function BaseOnboardingInterestedFeatures({shouldUseNativeStyles}: BaseOnboardin await completeOnboardingFlow({featuresMap}); }, [completeOnboardingFlow, features, isAccountingEnabled, selectedFeatures]); - // Create items for enabled features + // Create items for enabled features, title-only per the mocks const enabledFeatures: Feature[] = features .filter((feature) => !!feature.enabledByDefault || feature.id === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED) .map((feature) => ({ ...feature, + subtitle: undefined, })); // Create items for features they may be interested in @@ -191,33 +249,34 @@ function BaseOnboardingInterestedFeatures({shouldUseNativeStyles}: BaseOnboardin }} accessibilityLabel={item.title} accessible={false} - hoverStyle={styles.hoveredComponentBG} + // Keep the selected fill on hover. + hoverStyle={isSelected ? undefined : styles.hoveredComponentBG} style={[ - styles.onboardingInterestedFeaturesItem, - // 48.5% handles the gap between columns and keeps items aligned when the scrollbar appears - isSmallScreenWidth ? styles.flexBasis100 : {flexBasis: '48.5%', maxWidth: '48.5%'}, + styles.onboardingTile, + isSmallScreenWidth && !isInLandscapeMode ? styles.onboardingTileNarrow : styles.onboardingInterestedFeaturesItemWide, + isSelected && styles.onboardingTileSelected, ]} sentryLabel={CONST.SENTRY_LABEL.ONBOARDING.INTERESTED_FEATURES_ITEM} > - - - {item.title} - { handleFeatureSelect(item.id); }} + wrapperStyle={styles.onboardingTileSelectionButton} + /> + + {item.title} + {!!item.subtitle && {item.subtitle}} ); }, - [styles, isSmallScreenWidth, selectedFeatures, handleFeatureSelect], + [styles, isSmallScreenWidth, isInLandscapeMode, selectedFeatures, handleFeatureSelect], ); const renderSection = useCallback( diff --git a/src/pages/OnboardingInterestedFeatures/types.ts b/src/pages/OnboardingInterestedFeatures/types.ts index 4f90bf7413ad..18effacf3987 100644 --- a/src/pages/OnboardingInterestedFeatures/types.ts +++ b/src/pages/OnboardingInterestedFeatures/types.ts @@ -13,6 +13,7 @@ type BaseOnboardingInterestedFeaturesProps = OnboardingInterestedFeaturesProps & type Feature = { id: string; title: string; + subtitle?: string; icon: IconAsset; enabledByDefault?: boolean; requiresUpdate?: boolean; diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index abbd950d3aa7..6775d535eee3 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -25,7 +25,7 @@ import getOperatingSystem from '@libs/getOperatingSystem'; import Navigation from '@libs/Navigation/Navigation'; import {AddWorkEmail} from '@userActions/Session'; -import {addWorkEmailFormError, clearWorkEmailFormErrors, setOnboardingErrorMessage, setOnboardingMergeAccountStepValue} from '@userActions/Welcome'; +import {addWorkEmailFormError, clearWorkEmailFormErrors, setOnboardingErrorMessage, setOnboardingMergeAccountStepValue, setOnboardingShouldValidate} from '@userActions/Welcome'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -35,7 +35,6 @@ import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/OnboardingWorkEmailForm'; import type IconAsset from '@src/types/utils/IconAsset'; -import {useIsFocused} from '@react-navigation/native'; import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; import {PUBLIC_DOMAINS_SET, Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; @@ -73,7 +72,6 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai const {isOffline} = useNetwork(); const ICON_SIZE = 48; const operatingSystem = getOperatingSystem(); - const isFocused = useIsFocused(); useEffect(() => { setOnboardingErrorMessage(null); @@ -106,7 +104,9 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai setOnboardingErrorMessage(null); if (onboardingValues?.shouldValidate) { - Navigation.navigate(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.getRoute()); + // Replace instead of push so this screen unmounts. Left mounted, its effect re-runs once the merge response + // updates the account and pushes a second copy of the remaining flow. + Navigation.navigate(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.getRoute(), {forceReplace: true}); return; } @@ -118,14 +118,21 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai onboardingValues?.shouldValidate, isVsb, isSmb, - isFocused, onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.isMergeAccountStepSkipped, ]); - const submitWorkEmail = useCallback((values: FormOnyxValues) => { - AddWorkEmail(values[INPUT_IDS.ONBOARDING_WORK_EMAIL].trim()); - }, []); + const submitWorkEmail = (values: FormOnyxValues) => { + const submittedWorkEmail = values[INPUT_IDS.ONBOARDING_WORK_EMAIL].trim(); + + // AddWorkEmail already made this the account's login, and resending it would merge the account into itself, so restore the shouldValidate: false that going back cleared + if (session?.email && submittedWorkEmail.toLowerCase() === session.email.toLowerCase()) { + setOnboardingShouldValidate(false); + return; + } + + AddWorkEmail(submittedWorkEmail); + }; useEffect(() => { if (!onboardingErrorMessageTranslationKey) { @@ -164,11 +171,15 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai const emailParts = userEmail.split('@'); const domain = emailParts.at(1) ?? ''; - if (session?.email && userEmail.toLowerCase() === session.email.toLowerCase() && !isOffline) { - addErrorMessage(errors, INPUT_IDS.ONBOARDING_WORK_EMAIL, translate('onboarding.workEmailValidationError.sameAsSignupEmail')); - } else if ((!Str.isValidEmail(userEmail) || PUBLIC_DOMAINS_SET.has(domain.toLowerCase())) && !isOffline) { + // AddWorkEmail can make the submitted work email the account's login, so session.email is only the signup email while it is still on a public domain + if ((!Str.isValidEmail(userEmail) || PUBLIC_DOMAINS_SET.has(domain.toLowerCase())) && !isOffline) { Log.hmmm('User is trying to add an invalid work email', {userEmail, domain}); - addErrorMessage(errors, INPUT_IDS.ONBOARDING_WORK_EMAIL, translate('onboarding.workEmailValidationError.publicEmail')); + const isSignupEmail = !!session?.email && userEmail.toLowerCase() === session.email.toLowerCase(); + addErrorMessage( + errors, + INPUT_IDS.ONBOARDING_WORK_EMAIL, + translate(isSignupEmail ? 'onboarding.workEmailValidationError.sameAsSignupEmail' : 'onboarding.workEmailValidationError.publicEmail'), + ); } if (isOffline ?? false) { diff --git a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx index 90c918e2bb4e..41b6c57b82d5 100644 --- a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx +++ b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx @@ -4,6 +4,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import ValidateCodeForm from '@components/ValidateCodeActionModal/ValidateCodeForm'; +import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -21,7 +22,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {useIsFocused} from '@react-navigation/native'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -42,7 +42,6 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles}: BaseOnboardi const isSmb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB; const [onboardingErrorMessage] = useOnyx(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY); const isValidateCodeFormSubmitting = AccountUtils.isValidateCodeFormSubmitting(account); - const isFocused = useIsFocused(); useEffect(() => { if (onboardingValues?.isMergeAccountStepCompleted === undefined) { @@ -66,7 +65,21 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles}: BaseOnboardi } Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); - }, [onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.shouldRedirectToClassicAfterMerge, onboardingValues?.isMergeAccountStepSkipped, isVsb, isSmb, isFocused]); + }, [onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.shouldRedirectToClassicAfterMerge, onboardingValues?.isMergeAccountStepSkipped, isVsb, isSmb]); + + const handleBackPress = () => { + // The merge request cannot be cancelled. Leaving mid-flight lets the work email screen consume the success + // and route through PRIVATE_DOMAIN instead of Join a workspace. + if (isValidateCodeFormSubmitting) { + return true; + } + updateOnboardingValuesAndNavigation(onboardingValues); + return true; + }; + + // The work email screen force-replaces itself with this one, so Android's system Back would otherwise pop the whole + // onboarding modal while `shouldValidate` is still set. Run the same handler as the header Back button. + useAndroidBackButtonHandler(handleBackPress); const sendValidateCode = () => { if (!credentials?.login) { @@ -88,9 +101,7 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles}: BaseOnboardi > { - updateOnboardingValuesAndNavigation(onboardingValues); - }} + onBackButtonPress={handleBackPress} /> {onboardingValues?.isMergingAccountBlocked ? ( diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 70ae987d1234..b553c5bd631c 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -27,7 +27,7 @@ import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; import {askToJoinPolicy, joinAccessiblePolicy} from '@userActions/Policy/Member'; import {getAccessiblePolicies} from '@userActions/Policy/Policy'; import {completeOnboarding} from '@userActions/Report'; -import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome'; +import {setOnboardingAdminsChatReportID, setOnboardingErrorMessage, setOnboardingPolicyID} from '@userActions/Welcome'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -35,7 +35,7 @@ import ROUTES from '@src/ROUTES'; import type {JoinablePolicy} from '@src/types/onyx/JoinablePolicies'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import {useFocusEffect} from '@react-navigation/native'; +import {useFocusEffect, useNavigationState} from '@react-navigation/native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; @@ -82,7 +82,9 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); const isEmployerWithSubmit = onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER; const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); - const shouldHideBackButton = onboardingValues?.shouldValidate === false && route.params?.backTo === ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(); + // Nothing in this stack to go back to. The work email merge and the private domain screen force-replace into this + // screen, so it is the only route there. `canGoBack()` can't answer this: it bubbles to the root stack. + const shouldHideBackButton = useNavigationState((state) => state.routes.length === 1); const finishOnboarding = (policy: JoinablePolicy) => { const isJoiningSubmitPolicy = policy.policyType === CONST.POLICY.TYPE.SUBMIT; @@ -138,6 +140,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding policyID: policyInfo.policyID, keyForList: policyInfo.policyID, isDisabled: true, + itemStyle: onboardingIsMediumOrLargerScreenWidth ? [styles.pl8, styles.pr8, styles.cursorDefault] : [], rightElement: ( { + setOnboardingErrorMessage(null); + if (isEmployerWithSubmit) { autoCreateSubmitWorkspace(onboardingPersonalDetails?.firstName ?? '', onboardingPersonalDetails?.lastName ?? ''); return; @@ -210,7 +215,6 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding data={policyIDItems} onSelectRow={() => {}} ListItem={BareUserListItem} - style={{listItemWrapperStyle: onboardingIsMediumOrLargerScreenWidth ? [styles.pl8, styles.pr8, styles.cursorDefault] : []}} shouldShowLoadingPlaceholder={isLoadingJoinablePolicies || !!joinablePoliciesLoading || policyIDItems.length === 0} shouldStopPropagation showScrollIndicator diff --git a/src/pages/OnboardingWorkspaces/index.native.tsx b/src/pages/OnboardingWorkspaces/index.native.tsx index 3ed1db77de5c..8e0e1167d491 100644 --- a/src/pages/OnboardingWorkspaces/index.native.tsx +++ b/src/pages/OnboardingWorkspaces/index.native.tsx @@ -1,3 +1,5 @@ +import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; + import React from 'react'; import type {OnboardingWorkspacesProps} from './types'; @@ -5,6 +7,12 @@ import type {OnboardingWorkspacesProps} from './types'; import BaseOnboardingWorkspaces from './BaseOnboardingWorkspaces'; function OnboardingWorkspaces({...rest}: OnboardingWorkspacesProps) { + const {index: routeIndex} = rest.navigation.getState(); + + // Swallow system Back only when this is the first onboarding route, so it cannot pop the onboarding modal. + // Returning false on a later visit lets Navigation pop as normal. + useAndroidBackButtonHandler(() => routeIndex === 0); + return ( - + + + {!!item.brickRoadIndicator && } + ); } diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 91a982bb787a..47d906da812a 100755 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -22,6 +22,7 @@ import useIsSupportalSession from '@hooks/useIsSupportalSession'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import useSwitchToDelegator from '@hooks/useSwitchToDelegator'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -50,7 +51,7 @@ import {openAgentsPage} from '@userActions/Agent'; import {openExternalLink} from '@userActions/Link'; import {openPublicProfilePage} from '@userActions/PersonalDetails'; import {hasErrorInPrivateNotes} from '@userActions/Report'; -import {isAnonymousUser as isAnonymousUserSession} from '@userActions/Session'; +import {callFunctionIfActionIsAllowed, isAnonymousUser as isAnonymousUserSession} from '@userActions/Session'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -88,7 +89,7 @@ const reportsSelector = (reports: OnyxCollection) => mapOnyxCollectionIt function ProfilePage({route}: ProfilePageProps) { const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: reportsSelector}); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [personalDetails] = useAllPersonalDetails(); const [personalDetailsMetadata] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_METADATA); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -97,7 +98,7 @@ function ProfilePage({route}: ProfilePageProps) { const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const switchToDelegator = useSwitchToDelegator(); const guideCalendarLink = account?.guideDetails?.calendarLink ?? ''; - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Bug', 'MagnifyingGlass', 'Pencil', 'Phone', 'UserPlus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Bug', 'History', 'Pencil', 'Phone', 'UserPlus']); const accountID = Number(route.params?.accountID ?? CONST.DEFAULT_NUMBER_ID); const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); const isCurrentUser = currentUserAccountID === accountID; @@ -330,8 +331,8 @@ function ProfilePage({route}: ProfilePageProps) { {!!accountID && !isAnonymousUserSession() && ( { const query = buildQueryStringFromFilterFormValues({ type: CONST.SEARCH.DATA_TYPES.CHAT, @@ -351,15 +352,20 @@ function ProfilePage({route}: ProfilePageProps) { /> )} {Permissions.canUsePrivateNotes() && !isEmptyObject(report) && !!report.reportID && !isCurrentUser && ( - navigateToPrivateNotes(report, currentUserAccountID, true)} - wrapperStyle={styles.breakAll} - shouldShowRightIcon - brickRoadIndicator={hasErrorInPrivateNotes(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - /> + navigateToPrivateNotes(report, currentUserAccountID, true))}> + + + + + + {translate('privateNotes.title')} + + + {hasErrorInPrivateNotes(report) && } + + + + )} {isConcierge && !!guideCalendarLink && ( { Navigation.dismissModal(); }; - const handleNavigateToConciergeChat = () => navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: true}); + const handleNavigateToConciergeChat = () => + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: true}); return ( { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: true}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: true}); }; const bankAccountState = reimbursementAccount?.achData?.state ?? ''; const pendingAction = reimbursementAccount?.pendingAction; diff --git a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx index 830eb502cbc8..e1f53dd44121 100644 --- a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx +++ b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx @@ -7,6 +7,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useResetBankAccountModal from '@hooks/useResetBankAccountModal'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -53,6 +54,7 @@ function FinishChatCard({requiresTwoFactorAuth, reimbursementAccount, policy, se const {shouldUseNarrowLayout} = useResponsiveLayout(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -68,6 +70,7 @@ function FinishChatCard({requiresTwoFactorAuth, reimbursementAccount, policy, se introSelected, currentUserAccountID, isSelfTourViewed, + conciergePersonalDetails, shouldDismissModal: true, reportActionID: reimbursementAccount?.achData?.ACHRequestReportActionID, }); diff --git a/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx b/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx index 18b1b5d7e8ce..ffa2ace762f7 100644 --- a/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx +++ b/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx @@ -9,6 +9,7 @@ import UploadFile from '@components/UploadFile'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useReimbursementAccountSubmitCallback from '@hooks/useReimbursementAccountSubmitCallback'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -56,6 +57,7 @@ function KYBDocuments({onBackButtonPress, onSubmit}: KYBDocumentsProps) { const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const reimbursementAccountVerificationData = reimbursementAccount?.achData?.verifications?.externalApiResponses; @@ -68,6 +70,7 @@ function KYBDocuments({onBackButtonPress, onSubmit}: KYBDocumentsProps) { introSelected, currentUserAccountID, isSelfTourViewed, + conciergePersonalDetails, shouldDismissModal: true, reportActionID: reimbursementAccount?.achData?.ACHRequestReportActionID, }); diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index 10a862929646..5d76af4b6c5c 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -17,6 +17,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -24,10 +25,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {search} from '@libs/actions/Search'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; -import {getSearchValueForPhoneOrEmail, getUserToInviteOption, sortAlphabetically} from '@libs/OptionsListUtils'; -import {getKnownAccountIDByLogin, getPersonalDetailsByID} from '@libs/PersonalDetailsUtils'; +import {getSearchValueForPhoneOrEmail, getUserToInviteOption} from '@libs/OptionsListUtils'; +import {getKnownAccountIDByLogin, getLoginByAccountID, getPersonalDetailsByID} from '@libs/PersonalDetailsUtils'; import {getAccountIDForSubmitManagerEmail, getMemberAccountIDsForWorkspace, getSubmitToEmail} from '@libs/PolicyUtils'; import {hasViolations as hasViolationsReportUtils, isExpenseReport, isMoneyRequestReportPendingDeletion} from '@libs/ReportUtils'; +import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; import {expensifyLoginsSelector} from '@libs/UserUtils'; @@ -38,7 +40,6 @@ import {searchUserInServer} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type Policy from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -93,9 +94,9 @@ function ReportSubmitToContent({ const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const delegateAccountID = useDelegateAccountID(); - const [submitterLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(report?.ownerAccountID)}); const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [personalDetails] = useAllPersonalDetails(); + const submitterLogin = getLoginByAccountID(report?.ownerAccountID, personalDetails); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index 60015feb132d..2b7f7061b380 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -1,5 +1,6 @@ import Button from '@components/Button'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; +import GpsDraftDetailsRefSync from '@components/GpsDraftDetailsRefSync'; import Icon from '@components/Icon'; import Text from '@components/Text'; @@ -8,6 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useRootNavigationState from '@hooks/useRootNavigationState'; import useShouldShowRequire2FAPage from '@hooks/useShouldShowRequire2FAPage'; +import useSignOut from '@hooks/useSignOut'; import useThemeStyles from '@hooks/useThemeStyles'; import useTwoFactorAuthRoute from '@hooks/useTwoFactorAuthRoute'; @@ -23,11 +25,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {emailSelector} from '@src/selectors/Session'; import type {Policy} from '@src/types/onyx'; +import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; import type {OnyxCollection} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/core'; -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; /** @@ -58,6 +61,9 @@ function RequireTwoFactorAuthenticationOverlay() { const styles = useThemeStyles(); const {translate} = useLocalize(); const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute(); + const {signOut, leaveDelegateAccount, isActingAsDelegate, isTrackingGPS} = useSignOut(); + const gpsDraftDetailsRef = useRef(undefined); + const [isEscapeInFlight, setIsEscapeInFlight] = useState(false); const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); @@ -90,7 +96,18 @@ function RequireTwoFactorAuthenticationOverlay() { snapshotOnboardingResumePathIfNeeded(); }, [shouldShowRequire2FAPage, isIn2FASetupFlow, snapshotOnboardingResumePathIfNeeded]); - const handleOnPress = () => { + const onEscapePress = () => { + if (isEscapeInFlight) { + return; + } + setIsEscapeInFlight(true); + const escapeAction = isActingAsDelegate ? leaveDelegateAccount({gpsDraftDetailsRef}) : signOut(); + escapeAction.finally(() => { + setIsEscapeInFlight(false); + }); + }; + + const enableTwoFactorAuth = () => { snapshotOnboardingResumePathIfNeeded(); Navigation.navigate(getTwoFactorAuthRoute(ROUTES.SETTINGS_SECURITY, {forceSetup: true})); }; @@ -100,40 +117,52 @@ function RequireTwoFactorAuthenticationOverlay() { } return ( - - - - - - - - - - {translate('twoFactorAuth.twoFactorAuthIsRequiredForAdminsHeader')} - - {translate(is2FARequiredBecauseOfXero ? 'twoFactorAuth.twoFactorAuthIsRequiredXero' : 'twoFactorAuth.twoFactorAuthIsRequiredCompany')} - + <> + {isActingAsDelegate && isTrackingGPS && } + + + + + + + + + + {translate('twoFactorAuth.twoFactorAuthIsRequiredForAdminsHeader')} + + {translate(is2FARequiredBecauseOfXero ? 'twoFactorAuth.twoFactorAuthIsRequiredXero' : 'twoFactorAuth.twoFactorAuthIsRequiredCompany')} + + + + + + - - - + + ); } diff --git a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx index f5d2163fc9cd..805daea38e34 100644 --- a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx @@ -5,7 +5,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ReportActionAvatars from '@components/ReportActionAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -126,12 +125,10 @@ function ScheduleCallConfirmationPage() { - { if (!route?.params?.reportID) { return; diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index 585c44834169..3d350a55b391 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -1,13 +1,16 @@ import ColumnsSettingsList from '@components/ColumnsSettingsList'; -import {useSearchQueryContext} from '@components/Search/SearchContext'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import useSearchColumnsToShow from '@components/Search/hooks/useSearchColumnsToShow'; +import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; import type {SearchCustomColumnIds} from '@components/Search/types'; import useIsVendorColumnAvailable from '@hooks/useIsVendorColumnAvailable'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import Navigation from '@libs/Navigation/Navigation'; import {buildQueryStringFromFilterFormValues, getCurrentSearchQueryJSON, hasValuesIncludeViolationFilter} from '@libs/SearchQueryUtils'; -import {getCustomColumnDefault, getCustomColumns, insertColumnBeforeTotalAmount} from '@libs/SearchUIUtils'; +import {getCustomColumnDefault, getCustomColumns, getValidGroupBy, insertColumnBeforeTotalAmount, isSearchDataLoaded} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -18,7 +21,9 @@ import React from 'react'; function SearchColumnsPage() { const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const {currentSearchKey} = useSearchQueryContext(); + const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); + const {currentSearchResults, shouldUseLiveData} = useSearchResultsContext(); + const {isOffline} = useNetwork(); const isVendorColumnAvailable = useIsVendorColumnAvailable(); const groupBy = searchAdvancedFiltersForm?.groupBy; @@ -35,7 +40,7 @@ function SearchColumnsPage() { const allGroupCustomColumns = getCustomColumns(groupBy); const defaultGroupCustomColumns = getCustomColumnDefault(groupBy); const defaultTypeCustomColumns = [...getCustomColumnDefault(queryType)]; - const currentColumns = [...(searchAdvancedFiltersForm?.columns ?? [])].filter(isColumnAvailable); + const savedColumns = [...(searchAdvancedFiltersForm?.columns ?? [])].filter(isColumnAvailable); // We need at least one element with flex1 in the table to ensure the table looks good in the UI, so we don't allow removing the total columns // since it makes sense for them to show up in an expense management App and it fixes the layout issues. @@ -58,11 +63,42 @@ function SearchColumnsPage() { if (shouldRequireViolationsColumn) { requiredColumns.add(CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); insertColumnBeforeTotalAmount(defaultTypeCustomColumns, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); - if (currentColumns.length > 0) { - insertColumnBeforeTotalAmount(currentColumns, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + if (savedColumns.length > 0) { + insertColumnBeforeTotalAmount(savedColumns, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); } } + // With no saved selection the table derives its columns from the data (e.g. Description shows up as soon + // as one expense has one), so seed the picker from that same set. Seeding from the static default list + // instead would show those columns as unchecked while the table renders them, and saving would pin the + // static list rather than what is on screen. + // + // The seed comes from the same hook the table uses, reading the query and the snapshot rather than the + // advanced-filters form. The form is only a mirror of the query and stops being written while category + // data loads, so it can still describe the previous search while the new snapshot is already rendered. + // + // Nothing to seed when the user already has a saved selection, and nothing to seed for a grouped search + // either: the table renders only GROUP_* columns there, none of which are selectable in this picker. + // Passing no snapshot keeps ColumnsSettingsList on its own group-defaults fallback instead of handing it + // an empty type-column selection. + const shouldSeedFromTable = savedColumns.length === 0 && !getValidGroupBy(currentSearchQueryJSON?.groupBy); + const tableColumns = useSearchColumnsToShow(currentSearchQueryJSON, shouldSeedFromTable ? currentSearchResults : undefined); + const selectableColumns = new Set(allTypeCustomColumns); + const renderedColumns = tableColumns.filter((column): column is SearchCustomColumnIds => selectableColumns.has(column)); + + const currentColumns = savedColumns.length > 0 ? savedColumns : renderedColumns; + + // A sort swaps in a new snapshot that has no data until the server responds, while the table keeps showing + // the previous rows. ColumnsSettingsList only reads its seed on mount, so wait for the snapshot instead of + // seeding from the static defaults and letting a Save pin them. + const isSeedPending = + shouldSeedFromTable && + !isOffline && + !shouldUseLiveData && + !!currentSearchQueryJSON && + !currentSearchResults?.data && + !isSearchDataLoaded(currentSearchResults, currentSearchQueryJSON); + const applyChanges = (selectedColumnIds: SearchCustomColumnIds[]) => { const updatedAdvancedFilters: Partial = { ...searchAdvancedFiltersForm, @@ -80,6 +116,10 @@ function SearchColumnsPage() { Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString, searchKey: currentSearchKey}), {forceReplace: true}); }; + if (isSeedPending) { + return ; + } + return ( { - const iouReportID = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]?.reportID; - return allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]?.ownerAccountID; - }), - ), - }); + const [personalDetailsList] = usePersonalDetailsByIDs( + selectedTransactionIDs.map((transactionID) => { + const iouReportID = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]?.reportID; + return allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]?.ownerAccountID; + }), + ); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); @@ -284,32 +283,6 @@ function SearchEditMultiplePage() { const tagsArray = getTagArrayFromName(draftTransaction?.tag ?? ''); const hasDependentTags = hasDependentTagsPolicyUtils(policy, policyTags); const shouldShowTagList = hasDependentTags ? getDependentTagVisibility(policyTagLists, draftTransaction?.tag) : []; - const tagFields: Array<{description: string; title: string; route: Route; disabled?: boolean}> = areTagsEnabled - ? policyTagLists.flatMap((tagList, tagListIndex) => { - const tagName = tagsArray.at(tagListIndex) ?? ''; - const tagTitle = tagName ? getCleanedTagName(tagName) : ''; - const description = tagList.name || translate('common.tag'); - let shouldShow = true; - - if (hasDependentTags) { - shouldShow = !!shouldShowTagList.at(tagListIndex); - } - - if (!shouldShow) { - return []; - } - - return [ - { - description: description || translate('common.tag'), - title: tagTitle, - route: ROUTES.SEARCH_EDIT_MULTIPLE_TAG_RHP.getRoute(tagListIndex), - disabled: false, - }, - ]; - }) - : []; - const getBooleanTitle = (value?: boolean) => { if (value === undefined) { return ''; @@ -317,82 +290,11 @@ function SearchEditMultiplePage() { return value ? translate('common.yes') : translate('common.no'); }; - const fields: Array<{description: string; title: string; route: Route; disabled?: boolean; shouldParseTitle?: boolean}> = [ - { - description: translate('iou.amount'), - title: draftTransaction?.amount !== undefined ? convertToDisplayStringWithoutCurrency(draftTransaction.amount, currency) : '', - route: ROUTES.SEARCH_EDIT_MULTIPLE_AMOUNT_RHP, - disabled: hasCustomUnitTransaction || hasPartiallyEditableTransaction || hasSplitTransaction, - }, - { - description: translate('common.description'), - title: draftTransaction?.comment?.comment ?? '', - route: ROUTES.SEARCH_EDIT_MULTIPLE_DESCRIPTION_RHP, - shouldParseTitle: true, - }, - { - description: translate('common.merchant'), - title: draftTransaction?.merchant ?? '', - route: ROUTES.SEARCH_EDIT_MULTIPLE_MERCHANT_RHP, - disabled: hasPartiallyEditableMerchantTransaction, - }, - { - description: translate('common.date'), - title: draftTransaction?.created ?? '', - route: ROUTES.SEARCH_EDIT_MULTIPLE_DATE_RHP, - disabled: hasPartiallyEditableDateTransaction, - }, - ...(areCategoriesEnabled - ? [ - { - description: translate('common.category'), - title: draftTransaction?.category ?? '', - route: ROUTES.SEARCH_EDIT_MULTIPLE_CATEGORY_RHP, - }, - ] - : []), - ...tagFields, - ...(isTaxTrackingEnabled - ? [ - { - description: policy?.taxRates?.name ?? translate('common.tax'), - title: draftTransaction?.taxCode ? (getTaxName(policy, draftTransaction) ?? '') : '', - route: ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP, - disabled: hasPartiallyEditableTaxRateTransaction || hasSplitTransaction, - }, - ] - : []), - ...(areSelectedTransactionsBillable - ? [ - { - description: translate('common.billable'), - title: getBooleanTitle(draftTransaction?.billable), - route: ROUTES.SEARCH_EDIT_MULTIPLE_BILLABLE_RHP, - disabled: isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.BILLABLE), - }, - ] - : []), - ...(areSelectedTransactionsReimbursable - ? [ - { - description: translate('common.reimbursable'), - title: getBooleanTitle(draftTransaction?.reimbursable), - route: ROUTES.SEARCH_EDIT_MULTIPLE_REIMBURSABLE_RHP, - disabled: hasPartiallyEditableReimbursableTransaction, - }, - ] - : []), - ...(areAttendeesEnabled - ? [ - { - description: translate('iou.attendees'), - title: draftTransaction?.comment?.attendees?.length ? getAttendeesListDisplayString(draftTransaction.comment.attendees, localeCompare) : '', - route: ROUTES.SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP, - disabled: isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.ATTENDEES), - }, - ] - : []), - ]; + const isAmountDisabled = hasCustomUnitTransaction || hasPartiallyEditableTransaction || hasSplitTransaction; + const isTaxDisabled = hasPartiallyEditableTaxRateTransaction || hasSplitTransaction; + const isBillableDisabled = isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.BILLABLE); + const isAttendeesDisabled = isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.ATTENDEES); + const description = draftTransaction?.comment?.comment; return ( {translate('search.bulkActions.editMultipleDescription')} - {fields.map((field) => ( - Navigation.navigate(field.route)} - shouldShowRightIcon={!field.disabled} - disabled={field.disabled} - interactive={!field.disabled} - shouldParseTitle={field.shouldParseTitle} + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_AMOUNT_RHP)} + isDisabled={isAmountDisabled} + /> + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_DESCRIPTION_RHP)} + /> + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_MERCHANT_RHP)} + isDisabled={hasPartiallyEditableMerchantTransaction} + /> + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_DATE_RHP)} + isDisabled={hasPartiallyEditableDateTransaction} + /> + {areCategoriesEnabled && ( + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_CATEGORY_RHP)} + /> + )} + {areTagsEnabled && + policyTagLists.map((tagList, tagListIndex) => { + if (hasDependentTags && !shouldShowTagList.at(tagListIndex)) { + return null; + } + const tagName = tagsArray.at(tagListIndex) ?? ''; + return ( + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_TAG_RHP.getRoute(tagListIndex))} + /> + ); + })} + {isTaxTrackingEnabled && ( + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP)} + isDisabled={isTaxDisabled} + /> + )} + {areSelectedTransactionsBillable && ( + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_BILLABLE_RHP)} + isDisabled={isBillableDisabled} + /> + )} + {areSelectedTransactionsReimbursable && ( + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_REIMBURSABLE_RHP)} + isDisabled={hasPartiallyEditableReimbursableTransaction} + /> + )} + {areAttendeesEnabled && ( + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP)} + isDisabled={isAttendeesDisabled} /> - ))} + )} + + + + ); +} + +export default EarlyRenewalOfferSection; diff --git a/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts b/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts index 826181772b36..83d806751b07 100644 --- a/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts +++ b/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts @@ -2,6 +2,7 @@ import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThrea import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {CAROUSEL_SOURCE, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import {isOneTransactionReport} from '@libs/ReportUtils'; @@ -170,16 +171,23 @@ function useReviewFlaggedExpenses(): ReviewFlaggedExpenses { const firstFlaggedReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${firstFlaggedExpense.reportID}`]; const firstFlaggedTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${firstFlaggedExpense.transactionID}`]; - // With a single flagged expense the review carousel has nothing to navigate between, and for a - // one-transaction report the transaction thread is a redundant duplicate of the report itself. - // Gate on the report's transaction count (not its type) so a lone flagged expense inside a - // multi-transaction report still opens that expense's thread rather than the whole report. - if (flaggedExpenses.length === 1 && isOneTransactionReport(firstFlaggedReport)) { - Navigation.navigate( - shouldUseNarrowLayout - ? ROUTES.REPORT_WITH_ID.getRoute(firstFlaggedExpense.reportID, undefined, undefined, ROUTES.HOME) - : ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: firstFlaggedExpense.reportID, backTo: ROUTES.HOME}), - ); + const siblingTransactionIDs = flaggedExpenses.map((flaggedExpense) => flaggedExpense.transactionID); + + if (isOneTransactionReport(firstFlaggedReport) && (flaggedExpenses.length === 1 || !shouldUseNarrowLayout)) { + // A lone flagged expense has nothing to page between, so it skips seeding the carousel entirely. + const shouldSeedCarousel = flaggedExpenses.length > 1; + const openReport = () => + Navigation.navigate( + shouldUseNarrowLayout + ? ROUTES.REPORT_WITH_ID.getRoute(firstFlaggedExpense.reportID, undefined, undefined, ROUTES.HOME) + : ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: firstFlaggedExpense.reportID, backTo: ROUTES.HOME}), + ); + + if (!shouldSeedCarousel) { + openReport(); + return; + } + setActiveTransactionIDs(siblingTransactionIDs, {source: CAROUSEL_SOURCE.homeReviewFlagged}).then(openReport); return; } @@ -188,7 +196,8 @@ function useReviewFlaggedExpenses(): ReviewFlaggedExpenses { reportActions: Object.values(firstFlaggedReportActions ?? {}), report: firstFlaggedReport, transaction: firstFlaggedTransaction, - siblingTransactionIDs: flaggedExpenses.map((flaggedExpense) => flaggedExpense.transactionID), + siblingTransactionIDs, + carouselSource: CAROUSEL_SOURCE.homeReviewFlagged, backTo: ROUTES.HOME, }); } diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts index aab813466ec1..145463f91914 100644 --- a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -4,6 +4,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnboardingIntent from '@hooks/useOnboardingIntent'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRulesPrefetch from '@hooks/useRulesPrefetch'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import {startMoneyRequest} from '@libs/actions/IOU/MoneyRequest'; @@ -35,6 +36,7 @@ import type {Route} from '@src/ROUTES'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import {hasIssuedExpensifyCardSelector} from '@selectors/Card'; +import {createHasExpenseDefaultRulesSelector} from '@selectors/Rule'; import {accountIDSelector} from '@selectors/Session'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; @@ -72,11 +74,15 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { const {shouldUseNarrowLayout} = useResponsiveLayout(); const intent = useOnboardingIntent(); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [hasMerchantRules] = useOnyx(ONYXKEYS.COLLECTION.RULE, {selector: createHasExpenseDefaultRulesSelector(activePolicyID)}); const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); const [reportedIntegration] = useOnyx(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${activePolicyID}`); + + // The checklist reads the rules collection, and nothing on Home opens a workspace to populate it. + useRulesPrefetch(arePolicyRulesEnabled(policy, policyCategories)); const [allCardFeeds] = useCardFeeds(activePolicyID); const workspaceAccountID = useWorkspaceAccountID(activePolicyID); @@ -348,7 +354,7 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { key: 'setupRules', label: translate('homePage.gettingStartedSection.setupRules'), subText: translate('homePage.gettingStartedSection.setupRulesSubText'), - isComplete: hasConfiguredRules(policy, policyCategories), + isComplete: hasConfiguredRules(policy, policyCategories, hasMerchantRules ?? false), route: ROUTES.WORKSPACE_RULES.getRoute(activePolicyID), }); } diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 0890196ac886..8b384ebbc67e 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -14,6 +14,8 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import openHomePage from '@libs/actions/HomePage'; + import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -21,9 +23,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ComponentRef} from 'react'; import {PortalHost} from '@gorhom/portal'; +import {useFocusEffect} from '@react-navigation/native'; import {useRef, useState} from 'react'; import {View} from 'react-native'; +import EarlyRenewalOfferSection from './EarlyRenewalOfferSection'; import ForYouSection from './ForYouSection'; import FreeTrialSection from './FreeTrialSection'; import GettingStartedSection from './GettingStartedSection'; @@ -41,7 +45,7 @@ function HomePage() { const styles = useThemeStyles(); const {translate} = useLocalize(); useDocumentTitle(translate('common.home')); - const {isOffline} = useNetwork(); + const {isOffline} = useNetwork({onReconnect: openHomePage}); const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [isLoadingReportData = false] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); // Offline the underlying commands never send, so the loading flags can stay true forever. Match useLoadingBarVisibility and hide the bar when offline. @@ -49,6 +53,10 @@ function HomePage() { const shouldShowHomeSkeleton = useAppLoadSkeletonVisibility(); const receiptDropTargetRef = useRef>(null); + useFocusEffect(() => { + openHomePage(); + }); + // Owned here (above the narrow/wide layout branch) so the Concierge "+" menu survives the ForYouSection remount that // happens on breakpoint change, converting between anchored popover and bottom-docked modal instead of vanishing. const [isConciergeMenuVisible, setIsConciergeMenuVisible] = useState(false); @@ -68,7 +76,8 @@ function HomePage() { // than replacing the whole layout, which would unmount the Concierge card and interrupt anyone typing in it. const homeLayout = shouldUseNarrowLayout ? ( <> - {/* Occupies a slot whether or not it renders, so the card below keeps its index across the swap. */} + {/* These occupy slots whether or not they render, so the card below keeps its index across the swap. */} + {shouldShowHomeSkeleton ? null : } {shouldShowHomeSkeleton ? null : } {forYouSection} {shouldShowHomeSkeleton ? ( @@ -110,6 +119,7 @@ function HomePage() { ) : ( <> + diff --git a/src/pages/home/InsightsSection/InsightsSectionContent.tsx b/src/pages/home/InsightsSection/InsightsSectionContent.tsx index c67d68fdbc45..94ef755a07f3 100644 --- a/src/pages/home/InsightsSection/InsightsSectionContent.tsx +++ b/src/pages/home/InsightsSection/InsightsSectionContent.tsx @@ -1,7 +1,6 @@ -import BlockingView from '@components/BlockingViews/BlockingView'; -import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/VictoryTheme'; import ChartEmptyState from '@components/Search/ChartEmptyState'; import ChartErrorState from '@components/Search/ChartErrorState'; +import ChartOfflineState from '@components/Search/ChartOfflineState'; import SearchChartView from '@components/Search/SearchChartView'; import WidgetContainer from '@components/WidgetContainer'; import WidgetHeaderMenu from '@components/WidgetHeaderMenu'; @@ -11,15 +10,13 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {setNameValuePair} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; +import {INSIGHTS_CHART_STATE} from '@libs/resolveInsightsChartData'; import type {SearchKey} from '@libs/SearchKeyUtils'; -import variables from '@styles/variables'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -29,13 +26,12 @@ import {View} from 'react-native'; import InsightTitleDropdown from './InsightTitleDropdown'; import useHomeInsightConfigs from './useHomeInsightConfigs'; -import useInsightData, {INSIGHT_STATE} from './useInsightData'; +import useInsightData from './useInsightData'; function InsightsSectionContent() { const styles = useThemeStyles(); const {translate} = useLocalize(); - const theme = useTheme(); - const icons = useMemoizedLazyExpensifyIcons(['Expand', 'OfflineCloud']); + const icons = useMemoizedLazyExpensifyIcons(['Expand']); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isBetaEnabled} = usePermissions(); const isInsightsPageEnabled = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); @@ -45,7 +41,7 @@ function InsightsSectionContent() { // The persisted key can name an insight the user is no longer eligible for, so fall back to the first option. const config = insightConfigs.find((insightConfig) => insightConfig.key === selectedKey) ?? insightConfigs.at(0); - const {queryJSON, groupBy, view, sortedData, state, retry} = useInsightData(config, isConfigResolved && selectedKeyMetadata.status === 'loaded'); + const {queryJSON, groupBy, view, data, state, retry} = useInsightData(config, isConfigResolved && selectedKeyMetadata.status === 'loaded'); const onSelectInsight = (key: SearchKey) => { if (key === config?.key) { @@ -68,7 +64,7 @@ function InsightsSectionContent() { /> } titleRightContent={ - state === INSIGHT_STATE.READY || state === INSIGHT_STATE.EMPTY || state === INSIGHT_STATE.ERROR ? ( + state === INSIGHTS_CHART_STATE.READY || state === INSIGHTS_CHART_STATE.EMPTY || state === INSIGHTS_CHART_STATE.ERROR ? ( - {state === INSIGHT_STATE.OFFLINE && ( - - )} - {state === INSIGHT_STATE.EMPTY && } - {state === INSIGHT_STATE.ERROR && } - {(state === INSIGHT_STATE.LOADING || state === INSIGHT_STATE.READY) && ( - + {state === INSIGHTS_CHART_STATE.OFFLINE && } + {state === INSIGHTS_CHART_STATE.EMPTY && } + {state === INSIGHTS_CHART_STATE.ERROR && } + {(state === INSIGHTS_CHART_STATE.LOADING || state === INSIGHTS_CHART_STATE.READY) && ( + diff --git a/src/pages/home/InsightsSection/useInsightData.ts b/src/pages/home/InsightsSection/useInsightData.ts index 6c17ee302635..bf66e77f60c4 100644 --- a/src/pages/home/InsightsSection/useInsightData.ts +++ b/src/pages/home/InsightsSection/useInsightData.ts @@ -1,56 +1,32 @@ import useGroupedItems from '@components/Search/hooks/useGroupedItems'; -import type {ChartView, GroupedItem, SearchQueryJSON, SearchView} from '@components/Search/types'; +import type {ChartView, SearchView} from '@components/Search/types'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useTabFocusedRefresh from '@hooks/useTabFocusedRefresh'; import {search} from '@libs/actions/Search'; +import {INSIGHTS_CHART_STATE, resolveInsightsChartData} from '@libs/resolveInsightsChartData'; import type {SearchTypeMenuItem} from '@libs/SearchUIUtils'; -import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type SearchResults from '@src/types/onyx/SearchResults'; +import SCREENS from '@src/SCREENS'; +import type {Policy} from '@src/types/onyx'; -import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; - -import {useIsFocused} from '@react-navigation/native'; -import {useEffect, useEffectEvent} from 'react'; - -const INSIGHT_STATE = { - OFFLINE: 'offline', - ERROR: 'error', - LOADING: 'loading', - EMPTY: 'empty', - READY: 'ready', -} as const; +import type {OnyxCollection} from 'react-native-onyx'; function isChartView(view: SearchView): view is ChartView { return view === CONST.SEARCH.VIEW.BAR || view === CONST.SEARCH.VIEW.LINE || view === CONST.SEARCH.VIEW.PIE; } -function getInsightState( - isOffline: boolean, - searchResults: OnyxEntry, - queryJSON: SearchQueryJSON | undefined, - sortedData: GroupedItem[] | undefined, -): ValueOf { - const isDataLoaded = isSearchDataLoaded(searchResults, queryJSON); - - if (isOffline && !isDataLoaded) { - return INSIGHT_STATE.OFFLINE; - } - if (!isOffline && Object.keys(searchResults?.errors ?? {}).length > 0) { - return INSIGHT_STATE.ERROR; - } - if (!isDataLoaded) { - return INSIGHT_STATE.LOADING; - } - if (!sortedData?.length) { - return INSIGHT_STATE.EMPTY; - } - return INSIGHT_STATE.READY; +/** Joining a workspace adds its members' spend to the chart without touching any local expense. */ +function policyIDsSelector(policies: OnyxCollection): string { + return Object.values(policies ?? {}) + .map((policy) => policy?.id) + .filter((policyID): policyID is string => !!policyID) + .sort() + .join(','); } function useInsightData(config: SearchTypeMenuItem | undefined, isConfigResolved = true) { @@ -62,7 +38,9 @@ function useInsightData(config: SearchTypeMenuItem | undefined, isConfigResolved const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON?.hash}`); const {isOffline} = useNetwork(); - const isFocused = useIsFocused(); + // The chart is built from a snapshot that no update patches, so an expense change has to move the key. + const [spendDataSignature] = useOnyx(ONYXKEYS.DERIVED.SPEND_DATA_SIGNATURE); + const [policyIDs] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policyIDsSelector}); const retry = () => { // `search.isLoading` is persisted and may be stale after a reload. Call `search()` again and let it ignore a request that is still running. @@ -81,30 +59,20 @@ function useInsightData(config: SearchTypeMenuItem | undefined, isConfigResolved }); }; - const onConfigChanged = useEffectEvent(() => { - retry(); - }); - - useEffect(() => { - if (!isFocused) { - return; - } - onConfigChanged(); - }, [queryJSON?.hash, isOffline, isFocused, isConfigResolved]); + useTabFocusedRefresh(SCREENS.HOME, [queryJSON?.hash, isOffline, isConfigResolved, spendDataSignature?.expenses ?? 0, policyIDs ?? ''].join('|'), retry); const sortedData = useGroupedItems(searchResults, queryJSON); - const state = isConfigResolved ? getInsightState(isOffline, searchResults, queryJSON, sortedData) : INSIGHT_STATE.LOADING; + const {data, state} = isConfigResolved ? resolveInsightsChartData({snapshot: searchResults, queryJSON, sortedData, isOffline}) : {data: [], state: INSIGHTS_CHART_STATE.LOADING}; return { queryJSON, groupBy, view, - sortedData, + data, state, retry, }; } -export {INSIGHT_STATE, getInsightState}; export default useInsightData; diff --git a/src/pages/home/RecentlyAddedSection/index.tsx b/src/pages/home/RecentlyAddedSection/index.tsx index 14231d5b6183..931acf6c286d 100644 --- a/src/pages/home/RecentlyAddedSection/index.tsx +++ b/src/pages/home/RecentlyAddedSection/index.tsx @@ -11,7 +11,7 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {CAROUSEL_SOURCE, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import Navigation from '@libs/Navigation/Navigation'; import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; import type {TransactionThreadNavigationDescriptor} from '@libs/TransactionThreadNavigationUtils'; @@ -83,9 +83,12 @@ function RecentlyAddedSection() { // Each row opens a single-expense view that always lands in (Wide) RHP on both layouts so the carousel // arrows are available. Marking the report as an expense lets the RHP open wide immediately, before its // data loads, instead of flickering from narrow to wide. - setActiveTransactionIDs(siblingTransactionIDs, siblingDescriptorsByTransactionID).then(() => { + setActiveTransactionIDs(siblingTransactionIDs, {source: CAROUSEL_SOURCE.homeRecentlyAdded, descriptors: siblingDescriptorsByTransactionID}).then(() => { markReportRHPWidth(reportID, 'wide'); - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo: ROUTES.HOME})); + // The anchor is what lets the header show the carousel on a cold open: getReportIDToOpenForExpense + // resolves the snapshot's childReportID without materializing the thread, so the header has no + // transaction of its own until OpenReport round-trips, and the counter would pop in only after that. + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo: ROUTES.HOME, anchorTransactionID: expense.transactionID})); }); }; diff --git a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts index 4b8f82966c7e..00b9a301afa0 100644 --- a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts +++ b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts @@ -2,21 +2,22 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useTabFocusedRefresh from '@hooks/useTabFocusedRefresh'; import {search} from '@libs/actions/Search'; -import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import {getExpenseCreationIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import {buildQueryStringFromFilterFormValues, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getAmount, getCreated, getCurrency, getMerchantName, getTransactionPendingAction} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; import type {Report, ReportAction, Transaction} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {OnyxCollection} from 'react-native-onyx'; -import {useIsFocused} from '@react-navigation/native'; -import {useEffect, useEffectEvent, useMemo, useState} from 'react'; +import {useMemo, useState} from 'react'; /** A single expense row surfaced by the Recently added slot. */ type RecentlyAddedExpense = { @@ -98,7 +99,6 @@ function useRecentlyAddedData(): RecentlyAddedData { const {accountID} = useCurrentUserPersonalDetails(); const {isOffline} = useNetwork(); const {translate} = useLocalize(); - const isFocused = useIsFocused(); const query = useMemo( () => @@ -112,6 +112,8 @@ function useRecentlyAddedData(): RecentlyAddedData { const hash = queryJSON?.hash; const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`); + const [spendDataSignature] = useOnyx(ONYXKEYS.DERIVED.SPEND_DATA_SIGNATURE); + // Read by key only, never iterated: the collection holds tens of thousands of entries. const [localTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [pendingTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {selector: pendingTransactionIDsSelector}); @@ -125,7 +127,7 @@ function useRecentlyAddedData(): RecentlyAddedData { // keeps it suppressed until the snapshot catches up. const [deletedTransactionIDs, setDeletedTransactionIDs] = useState(() => new Set()); - const fireSearch = useEffectEvent(() => { + const fireSearch = () => { if (isOffline || !queryJSON) { return; } @@ -139,14 +141,10 @@ function useRecentlyAddedData(): RecentlyAddedData { // The query only filters on the current accountID, which is available before OpenApp responds. Don't sit behind it. skipWaitForWrites: true, }); - }); + }; - useEffect(() => { - if (!isFocused) { - return; - } - fireSearch(); - }, [isFocused, isOffline, hash]); + // The list below only patches in expenses made on this device. One made elsewhere arrives here. + useTabFocusedRefresh(SCREENS.HOME, [hash, isOffline, spendDataSignature?.expenses ?? 0].join('|'), fireSearch); const snapshotData = searchResults?.data; @@ -271,7 +269,7 @@ function useRecentlyAddedData(): RecentlyAddedData { // displayed amount must be negated for them (mirrors the Search transaction list). amount: getAmount(sourceTransaction, isFromExpenseReport, isFromTrackedExpense), currency: getCurrency(sourceTransaction), - reportAction: getIOUActionForTransactionID(snapshotReportActions, transaction.transactionID), + reportAction: getExpenseCreationIOUActionForTransactionID(snapshotReportActions, transaction.transactionID), report: reportByReportID.get(transaction.reportID), // Derive from the local copy so an offline edit (which sets `pendingFields`, not `pendingAction`) // still surfaces the pending state, alongside offline creates (ADD) and deletes (DELETE). diff --git a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx index 4e3f1809a749..0060836f5874 100644 --- a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx +++ b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx @@ -6,6 +6,7 @@ import useDelegateAccountID from '@hooks/useDelegateAccountID'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import {pressLockedBankAccount} from '@libs/actions/BankAccounts'; import {navigateToConciergeChat} from '@libs/actions/Report'; @@ -30,6 +31,7 @@ function UnlockBankAccount({bankAccountID, policyName}: UnlockBankAccountProps) const icons = useMemoizedLazyExpensifyIcons(['BankLock']); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); @@ -49,7 +51,7 @@ function UnlockBankAccount({bankAccountID, policyName}: UnlockBankAccountProps) return; } pressLockedBankAccount(bankAccountID, translate, conciergeReportID, delegateAccountID, initiatingBankAccountUnlock); - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); }; return ( diff --git a/src/pages/home/YourSpendSection/useYourSpendData.ts b/src/pages/home/YourSpendSection/useYourSpendData.ts index 1c717a13cd89..8aa681848b6c 100644 --- a/src/pages/home/YourSpendSection/useYourSpendData.ts +++ b/src/pages/home/YourSpendSection/useYourSpendData.ts @@ -2,6 +2,7 @@ import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useTabFocusedRefresh from '@hooks/useTabFocusedRefresh'; import {search} from '@libs/actions/Search'; import {WRITE_COMMANDS} from '@libs/API/types'; @@ -12,6 +13,7 @@ import {isGroupEntry} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; import type {Card, Policy, Report} from '@src/types/onyx'; import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type {AnyRequest} from '@src/types/onyx/Request'; @@ -20,8 +22,7 @@ import type SearchResults from '@src/types/onyx/SearchResults'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import {useIsFocused} from '@react-navigation/native'; -import {useEffect, useEffectEvent, useMemo, useState} from 'react'; +import {useMemo, useState} from 'react'; import {YOUR_SPEND_CARD_KIND, YOUR_SPEND_ROW_STATE} from './const'; import {buildAwaitingApprovalQuery, buildCardGroupQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from './queries'; @@ -376,10 +377,10 @@ function getYourSpendRowState({isApplicable, isOffline, searchResults}: GetYourS function useYourSpendData(): UseYourSpendDataReturn { const {accountID} = useCurrentUserPersonalDetails(); const {isOffline} = useNetwork(); - const isFocused = useIsFocused(); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [spendDataSignature] = useOnyx(ONYXKEYS.DERIVED.SPEND_DATA_SIGNATURE); const {isApprovalApplicable, isPaymentApplicable, paidGroupPolicyIDs} = getYourSpendApplicability(policies); @@ -407,6 +408,7 @@ function useYourSpendData(): UseYourSpendDataReturn { selector: (reports) => getYourSpendReportsSignature(reports, paidGroupPolicyIDs, accountID), }); const outstandingReportsSignature = reportsSignature?.outstandingReportIDs ?? ''; + const reimbursedReportsSignature = reportsSignature?.reimbursedReportIDs ?? ''; const [queuedSpendRequests] = useOnyx(ONYXKEYS.PERSISTED_REQUESTS, {selector: projectQueuedSpendRequests}); const [ongoingSpendRequests] = useOnyx(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, {selector: projectOngoingSpendRequest}); const pendingSpendBuckets = useMemo( @@ -539,52 +541,57 @@ function useYourSpendData(): UseYourSpendDataReturn { const {state: approvalRowState, totals: approvalTotals} = useOfflineFrozenSpendRow(isOffline, isApprovalApplicable, approvalRowStateLive, approvalTotalsLive, approvalHash); const {state: paymentRowState, totals: paymentTotals} = useOfflineFrozenSpendRow(isOffline, isPaymentApplicable, paymentRowStateLive, paymentTotalsLive, paymentQueryJSON?.hash); - // Re-fires the search effect when applicability flips, the user joins/leaves a workspace - // (which changes the policyID filter), or the set of OUTSTANDING reports changes. - const applicabilityKey = [isApprovalApplicable ? 1 : 0, isPaymentApplicable ? 1 : 0, paidGroupPolicyIDs.join(','), outstandingReportsSignature].join('|'); - - const fireSearches = useEffectEvent(() => { - if (isOffline) { + // One key per search, so a card charge does not also refetch the approval and payment totals. + const fireCardSearch = () => { + if (isOffline || !cardGroupQueryJSON) { return; } - if (cardGroupQueryJSON) { - search({ - queryJSON: cardGroupQueryJSON, - searchKey: undefined, - offset: 0, - isLoading: false, - shouldCalculateTotals: true, - shouldUpdateLastSearchParams: false, - }); - } - if (isApprovalApplicable && approvalQueryJSON) { - search({ - queryJSON: approvalQueryJSON, - searchKey: undefined, - offset: 0, - isLoading: false, - shouldCalculateTotals: true, - shouldUpdateLastSearchParams: false, - }); - } - if (isPaymentApplicable && paymentQueryJSON) { - search({ - queryJSON: paymentQueryJSON, - searchKey: undefined, - offset: 0, - isLoading: false, - shouldCalculateTotals: true, - shouldUpdateLastSearchParams: false, - }); + search({ + queryJSON: cardGroupQueryJSON, + searchKey: undefined, + offset: 0, + isLoading: false, + shouldCalculateTotals: true, + shouldUpdateLastSearchParams: false, + }); + }; + + const fireApprovalSearch = () => { + if (isOffline || !isApprovalApplicable || !approvalQueryJSON) { + return; } - }); + search({ + queryJSON: approvalQueryJSON, + searchKey: undefined, + offset: 0, + isLoading: false, + shouldCalculateTotals: true, + shouldUpdateLastSearchParams: false, + }); + }; - useEffect(() => { - if (!isFocused) { + const firePaymentSearch = () => { + if (isOffline || !isPaymentApplicable || !paymentQueryJSON) { return; } - fireSearches(); - }, [isFocused, isOffline, cardGroupQueryJSON?.hash, applicabilityKey, accountID]); + search({ + queryJSON: paymentQueryJSON, + searchKey: undefined, + offset: 0, + isLoading: false, + shouldCalculateTotals: true, + shouldUpdateLastSearchParams: false, + }); + }; + + useTabFocusedRefresh(SCREENS.HOME, [isOffline, accountID, cardGroupQueryJSON?.hash, spendDataSignature?.cardExpenses ?? 0].join('|'), fireCardSearch); + useTabFocusedRefresh( + SCREENS.HOME, + // The expense counter catches edits on a report that is already outstanding, which leave the report list unchanged. + [isOffline, accountID, approvalQueryJSON?.hash, isApprovalApplicable ? 1 : 0, paidGroupPolicyIDs.join(','), outstandingReportsSignature, spendDataSignature?.expenses ?? 0].join('|'), + fireApprovalSearch, + ); + useTabFocusedRefresh(SCREENS.HOME, [isOffline, accountID, paymentQueryJSON?.hash, isPaymentApplicable ? 1 : 0, reimbursedReportsSignature].join('|'), firePaymentSearch); return { approvalRowState, diff --git a/src/pages/inbox/AccountManagerBanner.tsx b/src/pages/inbox/AccountManagerBanner.tsx deleted file mode 100644 index 46c90ab4860f..000000000000 --- a/src/pages/inbox/AccountManagerBanner.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import Banner from '@components/Banner'; - -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import Navigation from '@libs/Navigation/Navigation'; -import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import {isConciergeChatReport} from '@libs/ReportUtils'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; - -import {personalDetailsSelector} from '@selectors/PersonalDetails'; -import React, {useState} from 'react'; - -type AccountManagerBannerProps = { - reportID: string | undefined; -}; - -function AccountManagerBanner({reportID}: AccountManagerBannerProps) { - const styles = useThemeStyles(); - const {translate, formatPhoneNumber} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lightbulb']); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const [accountManagerData] = useOnyx(ONYXKEYS.ACCOUNT, { - selector: (account) => ({ - accountManagerReportID: account?.accountManagerReportID, - accountManagerAccountID: account?.accountManagerAccountID, - }), - }); - const accountManagerReportID = accountManagerData?.accountManagerReportID; - const accountManagerAccountID = accountManagerData?.accountManagerAccountID ?? CONST.DEFAULT_MISSING_ID; - const [participantPersonalDetail] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - selector: personalDetailsSelector(accountManagerAccountID), - }); - const [isBannerVisible, setIsBannerVisible] = useState(true); - - if (!accountManagerReportID || !isConciergeChatReport(report, conciergeReportID) || !isBannerVisible) { - return null; - } - const displayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: participantPersonalDetail, translate, formatPhoneNumber}); - const login = participantPersonalDetail?.login; - const chatWithAccountManagerText = displayName && login ? translate('common.chatWithAccountManager', `${displayName} (${login})`) : ''; - - const dismissBanner = () => setIsBannerVisible(false); - const chatWithAccountManager = () => { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(accountManagerReportID)); - }; - - return ( - - ); -} - -AccountManagerBanner.displayName = 'AccountManagerBanner'; - -export default AccountManagerBanner; diff --git a/src/pages/inbox/AgentZeroStatusContext.tsx b/src/pages/inbox/AgentZeroStatusContext.tsx index 6a913fb0c9be..62c775a5081c 100644 --- a/src/pages/inbox/AgentZeroStatusContext.tsx +++ b/src/pages/inbox/AgentZeroStatusContext.tsx @@ -1,4 +1,5 @@ import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import {clearConciergeThinkingKickoff, subscribeToReportReasoningEvents, unsubscribeFromReportReasoningChannel} from '@libs/actions/Report'; import AgentZeroOptimisticStore from '@libs/AgentZeroOptimisticStore'; @@ -82,7 +83,7 @@ const AgentZeroStatusActionsContext = createContext(defa function AgentZeroStatusProvider({reportID, children}: React.PropsWithChildren<{reportID: string | undefined}>) { const [reportMeta] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: reportMetaSelector}); const {chatType, isDM: isDMReport = false, parentReportID, participantAccountIDs} = reportMeta ?? {}; - const [agentParticipantAccountID] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: getCustomAgentParticipantAccountID(participantAccountIDs)}); + const [agentParticipantAccountID] = usePersonalDetailsByIDs(participantAccountIDs, getCustomAgentParticipantAccountID(participantAccountIDs)); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); diff --git a/src/pages/inbox/ConciergeDraftContext.tsx b/src/pages/inbox/ConciergeDraftContext.tsx index 07297f60c2ac..d646ab8a4ddb 100644 --- a/src/pages/inbox/ConciergeDraftContext.tsx +++ b/src/pages/inbox/ConciergeDraftContext.tsx @@ -1,4 +1,5 @@ import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import {isGroupPolicyByType} from '@libs/PolicyUtils'; import type {ConciergeDraftEvent} from '@libs/Pusher/types'; @@ -50,7 +51,7 @@ const ConciergeDraftActionsContext = createContext(defaul function ConciergeDraftProvider({reportID, children}: React.PropsWithChildren<{reportID: string | undefined}>) { const [chatType] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getReportChatType}); const [participantAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getReportParticipantAccountIDs}); - const [agentParticipantAccountID] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: getCustomAgentParticipantAccountID(participantAccountIDs)}); + const [agentParticipantAccountID] = usePersonalDetailsByIDs(participantAccountIDs, getCustomAgentParticipantAccountID(participantAccountIDs)); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [parentReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getReportParentReportID}); diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 94be13a4eb84..c6cd282640fa 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -27,6 +27,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -93,7 +94,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useRoute} from '@react-navigation/native'; import {accountGuideDetailsSelector} from '@selectors/Account'; -import {accountIDSelector, isOptimisticPersonalDetailSelector} from '@selectors/PersonalDetails'; +import {accountIDSelector, isPersonalDetailOptimistic} from '@selectors/PersonalDetails'; import {pendingChatMembersSelector} from '@selectors/ReportMetaData'; import {isPast} from 'date-fns'; import React, {useMemo} from 'react'; @@ -120,7 +121,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const [grandParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(parentReport?.parentReportID)}`); const grandParentReportAction = useParentReportAction(parentReport); const policy = usePolicy(report?.policyID); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [personalDetails] = useAllPersonalDetails(); const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL); @@ -155,7 +156,6 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const participants = allParticipants.slice(0, CONST.DISPLAY_PARTICIPANTS_LIMIT); const isMultipleParticipant = participants.length > 1; const firstParticipantAccountID = participants.at(0) ?? CONST.DEFAULT_NUMBER_ID; - const [isParticipantOptimistic = true] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: isOptimisticPersonalDetailSelector(firstParticipantAccountID)}); const participantPersonalDetails = getPersonalDetailsForAccountIDs(participants, personalDetails); const displayNamesWithTooltips = getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant, localeCompare, formatPhoneNumber, translate, undefined, isSelfDM); @@ -313,6 +313,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const shouldShowSubscript = shouldReportShowSubscript(report, isReportArchived); const brickRoadIndicator = hasReportNameError(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; + const isParticipantOptimistic = isPersonalDetailOptimistic(personalDetails?.[firstParticipantAccountID]); const shouldDisableDetailPage = shouldDisableDetailPageReportUtils(report, isParticipantOptimistic); const shouldUseGroupTitle = isGroupChat && (!!report?.reportName || !isMultipleParticipant); const isLoading = !report?.reportID || !title; diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 5ed69c8e97ef..dd456bcb912a 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -484,6 +484,27 @@ function ReportFetchHandler() { updateLoadingInitialReportAction(reportIDFromRoute, true); }, [reportIDFromRoute, reportLoadingState.hasOnceLoadedReportActions]); + // "Clear cache and restart" drops the memory-only `hasOnceLoadedReportActions` under a mounted report screen, and + // nothing re-fetches, so the effect above re-arms `isLoadingInitialReportActions` for a load that never comes and + // the report preview spins forever. Re-fetch only when a stamp this screen saw for the same reportID is lost, so a + // normal open or a switch to another report does not double up on the fetch effect below. See issue #100524. + const stampedReportIDRef = useRef(undefined); + useEffect(() => { + if (reportLoadingState.hasOnceLoadedReportActions) { + stampedReportIDRef.current = reportIDFromRoute; + return; + } + + // Held while the Inbox tab is merely preloaded, like every other fetch here: OpenReport would mark a report + // the user has never opened as read. The ref survives the bail, so opening the tab re-runs this effect and + // the fetch is deferred rather than lost. + if (stampedReportIDRef.current !== reportIDFromRoute || !isFocused || isOffline || isInPreloadedTab) { + return; + } + stampedReportIDRef.current = undefined; + fetchReport(); + }, [reportIDFromRoute, reportLoadingState.hasOnceLoadedReportActions, isFocused, isOffline, isInPreloadedTab]); + // isLoadingInitialReportActions only clears via OpenReport's success/failure Onyx update (no client timeout), so // a reconciliation stall that pauses the queue before that response arrives leaves the skeleton stuck with // nothing else to log it. See Expensify#667674. diff --git a/src/pages/inbox/ReportNavigateAwayHandler.tsx b/src/pages/inbox/ReportNavigateAwayHandler.tsx index 8eef1491ccad..531a5ba69f16 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -3,6 +3,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useIsOwnWorkspaceChatRef from '@hooks/useIsOwnWorkspaceChatRef'; import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -73,6 +74,7 @@ function ReportNavigateAwayHandler() { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); const [userLeavingStatus = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportIDFromRoute}`); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const isSelfTourViewed = onboarding?.selfTourViewed; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -142,7 +144,7 @@ function ReportNavigateAwayHandler() { } Navigation.isNavigationReady().then(() => { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: false}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); }); }); @@ -233,9 +235,20 @@ function ReportNavigateAwayHandler() { } // Fallback to Concierge - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); }); - }, [reportWasDeleted, previousReportWasDeleted, isFocused, deletedReportParentID, conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, reportIDFromRoute]); + }, [ + reportWasDeleted, + previousReportWasDeleted, + isFocused, + deletedReportParentID, + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + conciergePersonalDetails, + reportIDFromRoute, + ]); return null; } diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index e7f927b21ead..13553611777b 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -27,7 +27,6 @@ import {View} from 'react-native'; import type ReportScreenNavigationProps from './types'; -import AccountManagerBanner from './AccountManagerBanner'; import {ActionListContextProvider} from './ActionListContext'; import {AgentZeroStatusProvider} from './AgentZeroStatusContext'; import {ConciergeDraftProvider} from './ConciergeDraftContext'; @@ -132,7 +131,6 @@ function ReportScreen({route, navigation, shouldDeferReportActions = false}: Rep {!shouldDeferNonEssentials && } - {!shouldDeferNonEssentials && } ; + + /** Whether the report being viewed allows the Concierge feedback prompt on the message it hangs off */ + shouldAllowConciergeFeedback: boolean; shouldUseThreadDividerLine: boolean; transactionThreadReport: OnyxEntry; }; @@ -77,6 +80,7 @@ function AncestorReportActionItem({ isSelfTourViewed, linkedTransactionRouteError, parentReportAction, + shouldAllowConciergeFeedback, shouldUseThreadDividerLine, transactionThreadReport, }: AncestorReportActionItemProps) { @@ -89,6 +93,11 @@ function AncestorReportActionItem({ const {isBetaEnabled} = usePermissions(); + // The message shown above a thread belongs to the parent report, so its own actions decide whether it is the newest Concierge answer + const [latestConciergeFeedbackActionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(report?.reportID)}`, { + selector: getLatestConciergeFeedbackActionIDFromReportActions, + }); + const shouldDisplayThreadDivider = !isTripPreview(reportAction); const isAncestorReportArchived = isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`]); const canOpenAncestorReport = canCurrentUserOpenReport(report, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), hasGuidesEmails, isAncestorReportArchived); @@ -156,6 +165,7 @@ function AncestorReportActionItem({ isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} isThreadReportParentAction + isLatestConciergeFeedbackAction={shouldAllowConciergeFeedback && !!latestConciergeFeedbackActionID && latestConciergeFeedbackActionID === reportAction.reportActionID} linkedTransactionRouteError={linkedTransactionRouteError} /> diff --git a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx index ce5df390086d..f56bd19c3819 100755 --- a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -18,6 +18,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useReportAttributes, {useDerivedIsEmptyReport, useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import useReportIDToNameMap from '@hooks/useReportIDToNameMap'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -243,6 +244,7 @@ function BaseReportActionContextMenu({ const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const personalDetails = usePersonalDetails(); const reportAttributes = useReportAttributes(); @@ -304,6 +306,7 @@ function BaseReportActionContextMenu({ isHarvestReport, currentUserAccountID: currentUserPersonalDetails?.accountID, rules, + cardList, }), ); @@ -377,6 +380,8 @@ function BaseReportActionContextMenu({ const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding: enableEdgeToEdgeBottomSafeAreaPadding, style: wrapperStyle}); + const reportIDToName = useReportIDToNameMap(); + return ( (isVisible || shouldKeepOpen || !isMini) && ( @@ -392,6 +397,7 @@ function BaseReportActionContextMenu({ reportActions, childReportActions, originalReportActions, + reportIDToName, // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style reportAction: (reportAction ?? null) as ReportAction, reportID, diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 88573e1b5fdf..7e2138559971 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -27,6 +27,7 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import stripFollowupListFromHtml from '@libs/ReportActionFollowupUtils/stripFollowupListFromHtml'; import { getActionableCard3DSTransactionApprovalMessage, + getAgentPromptUpdatedMessage, getActionableCardFraudAlertMessage, getActionableMentionWhisperMessage, getAddedApprovalRuleMessage, @@ -35,6 +36,7 @@ import { getAddedConnectionMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, + getAutoCategorizeNewExpensesMessage, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, getCardConnectionBrokenMessage, @@ -114,6 +116,7 @@ import { getUpdatedDefaultTitleMessage, getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, + getUpdatedMemberWorkArrangementMessage, getUpdatedOwnershipMessage, getUpdatedProhibitedExpensesMessage, getUpdatedReimbursementChoiceMessage, @@ -228,6 +231,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type { BankAccountList, + CardList, Card, Download as DownloadOnyx, IntroSelected, @@ -267,12 +271,12 @@ function getActionHtml(reportAction: OnyxInputOrEntry): string { } /** Sets the HTML string to Clipboard */ -function setClipboardMessage(content: string | undefined) { +function setClipboardMessage(content: string | undefined, reportIDToName?: Record) { const strippedContent = stripFollowupListFromHtml(content); if (!strippedContent) { return; } - const clipboardText = getClipboardText(strippedContent); + const clipboardText = getClipboardText(strippedContent, reportIDToName); if (!Clipboard.canSetHtml()) { Clipboard.setString(clipboardText); } else { @@ -281,12 +285,13 @@ function setClipboardMessage(content: string | undefined) { } /** Sets the HTML string to Clipboard after stripping the SMS domain from any user mentions */ -function setClipboardMessageWithCleanedMentions(value: string) { +function setClipboardMessageWithCleanedMentions(value: string, reportIDToName?: Record) { setClipboardMessage( value.replaceAll(/()(.*?)(<\/mention-user>)/gi, (match, openTag: string, innerContent: string, closeTag: string): string => { const modifiedContent = Str.removeSMSDomain(innerContent) || ''; return openTag + modifiedContent + closeTag || ''; }), + reportIDToName, ); } @@ -316,6 +321,7 @@ type ShouldShow = (args: { isHarvestReport?: boolean; currentUserAccountID: number; rules: OnyxCollection; + cardList: OnyxEntry; }) => boolean; type ContextMenuActionPayload = { @@ -377,6 +383,7 @@ type ContextMenuActionPayload = { reportAttributes: ReportAttributesDerivedValue['reports'] | undefined; memberChangeLogRoomReportName: string | undefined; rules: OnyxCollection; + reportIDToName: Record; }; type OnPress = (closePopover: boolean, payload: ContextMenuActionPayload, selection?: string, reportID?: string) => void; @@ -651,6 +658,7 @@ const ContextMenuActions: ContextMenuAction[] = [ conciergeChat, isOffline, personalDetails, + reportIDToName, }, ) => { if (isMoneyRequestAction(reportAction) || isMoneyRequestAction(moneyRequestAction)) { @@ -667,7 +675,13 @@ const ContextMenuActions: ContextMenuAction[] = [ return; } const editAction = () => { - saveReportActionDraft(originalReportID ?? reportID, reportAction, originalReportActions ?? reportActions, Parser.htmlToMarkdown(getActionHtml(reportAction)), isOffline); + saveReportActionDraft( + originalReportID ?? reportID, + reportAction, + originalReportActions ?? reportActions, + Parser.htmlToMarkdown(getActionHtml(reportAction), {reportIDToName}), + isOffline, + ); }; if (closePopover) { @@ -1031,6 +1045,7 @@ const ContextMenuActions: ContextMenuAction[] = [ memberChangeLogRoomReportName, currentUserAccountID, rules, + reportIDToName, }, ) => { const isReportPreviewAction = isReportPreviewActionReportActionsUtils(reportAction); @@ -1043,7 +1058,7 @@ const ContextMenuActions: ContextMenuAction[] = [ if (selection) { // When the user has highlighted part of a message, always copy exactly what's selected, // regardless of the report action type (including system messages like "marked as paid"). - setClipboardMessageWithCleanedMentions(selection); + setClipboardMessageWithCleanedMentions(selection, reportIDToName); } else if (isReportPreviewAction) { const iouReportID = getIOUReportIDFromReportActionPreview(reportAction); const displayMessage = getReportPreviewMessageForCopy({ @@ -1173,6 +1188,8 @@ const ContextMenuActions: ContextMenuAction[] = [ Clipboard.setString(getAutoPayApprovedReportsEnabledMessage(translate, reportAction)); } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REQUIRE_COMPANY_CARDS_ENABLED) { Clipboard.setString(getRequireCompanyCardsEnabledMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_CATEGORIZE_NEW_EXPENSES) { + Clipboard.setString(getAutoCategorizeNewExpensesMessage(translate, reportAction)); } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REQUIRES_CATEGORY) { Clipboard.setString(getRequiresCategoryMessage(translate, reportAction)); } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REQUIRES_TAG) { @@ -1224,6 +1241,8 @@ const ContextMenuActions: ContextMenuAction[] = [ ); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) { Clipboard.setString(getMarkedReimbursedMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.AGENT_PROMPT_UPDATED)) { + Clipboard.setString(getAgentPromptUpdatedMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REIMBURSED)) { Clipboard.setString( getReimbursedMessage( @@ -1303,6 +1322,8 @@ const ContextMenuActions: ContextMenuAction[] = [ Clipboard.setString(translate('iou.heldExpense')); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { Clipboard.setString(translate('iou.unheldExpense')); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.UNDELETED_TRANSACTION) { + Clipboard.setString(translate('iou.undeletedExpense')); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REJECTEDTRANSACTION_THREAD) { Clipboard.setString(translate('iou.reject.reportActions.rejectedExpense')); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REJECTED_TRANSACTION_MARKASRESOLVED) { @@ -1425,6 +1446,8 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(getUpdatedDefaultTitleMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_HARVESTING)) { setClipboardMessage(getUpdatedAutoHarvestingMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT)) { + setClipboardMessage(getUpdatedMemberWorkArrangementMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INDIVIDUAL_BUDGET_NOTIFICATION)) { setClipboardMessage(getUpdatedIndividualBudgetNotificationMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SHARED_BUDGET_NOTIFICATION)) { @@ -1499,7 +1522,7 @@ const ContextMenuActions: ContextMenuAction[] = [ const missingFields = getOriginalMessage(reportAction)?.missingFields; setClipboardMessage(translate('violations.smartscanFailed', {canEdit: wasActionTakenByCurrentUser(iouAction, currentUserAccountID), missingFields})); } else if (content) { - setClipboardMessageWithCleanedMentions(content); + setClipboardMessageWithCleanedMentions(content, reportIDToName); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED)) { setClipboardMessage(getSettlementAccountLockedMessage(translate, reportAction)); } else if (messageText) { @@ -1689,6 +1712,7 @@ const ContextMenuActions: ContextMenuAction[] = [ childReportActions, currentUserAccountID, rules, + cardList, }) => { // A single-expense report preview also exposes its embedded money request action. // Preserve the expense-delete flow for its author. Otherwise, use the preview action so an admin @@ -1717,7 +1741,7 @@ const ContextMenuActions: ContextMenuAction[] = [ return ( !!reportIDParam && type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && - canDeleteReportAction(actionToDelete, reportID, iouTransaction, transactions, childReportActions, currentUserAccountID, rules) && + canDeleteReportAction(actionToDelete, reportID, iouTransaction, transactions, childReportActions, currentUserAccountID, rules, cardList) && !isArchivedRoom && !isChronosReport && !isMessageDeleted(reportAction) diff --git a/src/pages/inbox/report/DelegateOnBehalfOfText.tsx b/src/pages/inbox/report/DelegateOnBehalfOfText.tsx index abb8a8ba7418..8809288edc0f 100644 --- a/src/pages/inbox/report/DelegateOnBehalfOfText.tsx +++ b/src/pages/inbox/report/DelegateOnBehalfOfText.tsx @@ -1,14 +1,10 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import {personalDetailsSelector} from '@selectors/PersonalDetails'; import React from 'react'; type DelegateOnBehalfOfTextFallbackProps = { @@ -34,7 +30,7 @@ type DelegateOnBehalfOfTextProps = { function DelegateOnBehalfOfText({mainAccountID, fallbackLogin}: DelegateOnBehalfOfTextProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [resolvedDetail] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsSelector(mainAccountID ?? CONST.DEFAULT_NUMBER_ID)}); + const [resolvedDetail] = usePersonalDetail(mainAccountID); if (!resolvedDetail?.login) { return ; diff --git a/src/pages/inbox/report/HumanAgentAssistedByText.tsx b/src/pages/inbox/report/HumanAgentAssistedByText.tsx index 53591fb0545f..c016361a51a9 100644 --- a/src/pages/inbox/report/HumanAgentAssistedByText.tsx +++ b/src/pages/inbox/report/HumanAgentAssistedByText.tsx @@ -1,16 +1,16 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getHumanAgentFirstName} from '@libs/ReportActionsUtils'; +import {getHumanAgentAccountIDFromReportAction} from '@libs/ReportActionsUtils'; -import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {firstNameSelector} from '@selectors/PersonalDetails'; import React from 'react'; type HumanAgentAssistedByTextProps = { @@ -21,9 +21,7 @@ type HumanAgentAssistedByTextProps = { function HumanAgentAssistedByText({action}: HumanAgentAssistedByTextProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [humanAgentName] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - selector: (list: OnyxEntry) => getHumanAgentFirstName(action, list), - }); + const [humanAgentName] = usePersonalDetail(getHumanAgentAccountIDFromReportAction(action), firstNameSelector); return {translate('reportAction.assistedBy', humanAgentName ?? translate('reportAction.humanSupportAgent'))}; } diff --git a/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx b/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx index 2e96664b0ef1..f179325d2b09 100644 --- a/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx +++ b/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx @@ -2,6 +2,7 @@ import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import {getEmojiReactionDetails, mergeReactionsByEmoji} from '@libs/EmojiUtils'; @@ -39,9 +40,7 @@ function PopoverReactionList({isVisible, emojiName, reportActionID, anchorPositi const isReady = !!selectedReaction; const {emojiCodes = [], reactionCount = 0, hasUserReacted = false, userAccountIDs = []} = selectedReaction ? getEmojiReactionDetails(emojiName, selectedReaction, accountID) : {}; - const [users = getEmptyArray()] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - selector: multiPersonalDetailsSelector(isReady ? userAccountIDs : []), - }); + const [users = getEmptyArray()] = useAllPersonalDetails(multiPersonalDetailsSelector(isReady ? userAccountIDs : [])); // Hide the list when all reactions are removed useEffect(() => { diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx index e7c081c53871..552470830d6c 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx @@ -2,6 +2,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useReportRecipientLocalTime from '@hooks/useReportRecipientLocalTime'; import {getReportOfflinePendingActionAndErrors, getReportRecipientAccountIDs} from '@libs/ReportUtils'; @@ -11,7 +12,6 @@ import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {personalDetailsSelector} from '@selectors/PersonalDetails'; import React from 'react'; import {useComposerState} from './ComposerContext'; @@ -24,7 +24,7 @@ function ComposerLocalTime() { const canShowRecipientLocalTime = useReportRecipientLocalTime({report}); const shouldShow = canShowRecipientLocalTime && !isComposerFullSize; const reportRecipientAccountID = getReportRecipientAccountIDs(report, currentUserAccountID).at(0); - const [reportRecipient] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsSelector(shouldShow ? reportRecipientAccountID : undefined)}); + const [reportRecipient] = usePersonalDetail(shouldShow ? reportRecipientAccountID : undefined); if (!shouldShow || isEmptyObject(reportRecipient) || reportRecipient?.isCustomAgent) { return null; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 2b9e386320e5..9f73d1198880 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -11,6 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; +import useReportIDToNameMap from '@hooks/useReportIDToNameMap'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -285,6 +286,8 @@ function ComposerWithSuggestions({ const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const reportIDToName = useReportIDToNameMap(); + const commentRef = useRef(initialText); const {superWideRHPRouteKeys, wideRHPRouteKeys} = useWideRHPState(); @@ -611,7 +614,7 @@ function ComposerWithSuggestions({ webEvent.preventDefault(); if (lastReportAction) { const message = Array.isArray(lastReportAction?.message) ? (lastReportAction?.message?.at(-1) ?? null) : (lastReportAction?.message ?? null); - saveReportActionDraft(reportID, lastReportAction, reportActions, Parser.htmlToMarkdown(message?.html ?? ''), isOffline); + saveReportActionDraft(reportID, lastReportAction, reportActions, Parser.htmlToMarkdown(message?.html ?? '', {reportIDToName}), isOffline); } } // Flag emojis like "Wales" have several code points. Default backspace key action does not remove such flag emojis completely. @@ -661,6 +664,7 @@ function ComposerWithSuggestions({ lastReportAction, reportID, reportActions, + reportIDToName, updateComment, setCurrentEditMessageSelection, isOffline, diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 2d0f7fbcb3a2..d80fc2d53d64 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -3,6 +3,7 @@ import type {ComposerRef} from '@components/Composer/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; +import useReportIDToNameMap from '@hooks/useReportIDToNameMap'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; @@ -48,6 +49,7 @@ function useEditMessage({ const [personalDetails] = useAllPersonalDetails(); const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${actionOwnerReportID}`); const isOriginalReportArchived = useReportIsArchived(actionOwnerReportID); + const reportIDToName = useReportIDToNameMap(); const {stopEditing, submitEdit} = useReportActionActiveEditActions(); @@ -98,7 +100,16 @@ function useEditMessage({ submitEdit(); - editReportComment(originalReport, reportAction, trimmedNewDraft, isOriginalReportArchived, email ?? '', personalDetails, Object.fromEntries(draftMessageVideoAttributeCache)); + editReportComment( + originalReport, + reportAction, + trimmedNewDraft, + isOriginalReportArchived, + email ?? '', + personalDetails, + Object.fromEntries(draftMessageVideoAttributeCache), + reportIDToName, + ); deleteDraft(); } diff --git a/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts b/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts index 4d0fcd20a75a..e07cc0e9bd53 100644 --- a/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts +++ b/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts @@ -37,7 +37,7 @@ function useShouldAddOrReplaceReceipt(reportID: string) { const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); const canEditReceipt = canUserPerformWriteAction && - canEditFieldOfMoneyRequest({reportAction: effectiveParentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction, rules}) && + canEditFieldOfMoneyRequest({reportAction: effectiveParentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction, rules, reportNameValuePairs: undefined}) && !transaction?.receipt?.isTestDriveReceipt; const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 1b8a72000923..8f81eeaf67f6 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -18,6 +18,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -39,14 +40,15 @@ import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import Permissions from '@libs/Permissions'; import { extractLinksFromMessageHtml, + getAgentPromptUpdatedMessage, getIOUReportIDFromReportActionPreview, getOriginalMessage, getPaymentMessageWithExpectedDate, getReportActionMessage, getReportActionText, getWhisperedTo, - isCreatedTaskReportAction, isActionOfType, + isCreatedTaskReportAction, isDeletedParentAction as isDeletedParentActionUtils, isMessageDeleted, isMoneyRequestAction, @@ -77,6 +79,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {getStableReportSelector} from '@src/selectors/Report'; import {getReimbursedExpectedDateSelector} from '@src/selectors/ReportAction'; +import {isSplitContainerTransactionSelector, originalTransactionIDSelector} from '@src/selectors/Transaction'; import type * as OnyxTypes from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; @@ -86,7 +89,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {personalDetailsDisplayNameSelector} from '@selectors/PersonalDetails'; +import {displayNameOrDefaultSelector} from '@selectors/PersonalDetails'; import {deepEqual} from 'fast-equals'; import mapValues from 'lodash/mapValues'; import React, {useContext, useEffect, useRef, useState} from 'react'; @@ -207,6 +210,9 @@ function ReportActionItem({ const [linkedTransactionRouteError] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {selector: getLinkedTransactionRouteError}); + const [originalTransactionID] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {selector: originalTransactionIDSelector}); + const [isOriginalTransactionSplitContainer] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, {selector: isSplitContainerTransactionSelector}); + const {editingMessage, editingReportAction} = useReportActionActiveEdit(); const isConciergeGreeting = action.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID; @@ -214,9 +220,7 @@ function ReportActionItem({ const {transitionActionSheetState} = ActionSheetAwareScrollView.useActionSheetAwareScrollViewActions(); const {translate, datetimeToCalendarTime, formatPhoneNumber, dateFnsLocale} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); - const [actorDisplayName] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - selector: personalDetailsDisplayNameSelector(action.actorAccountID ?? CONST.DEFAULT_NUMBER_ID, translate, formatPhoneNumber), - }); + const [actorDisplayName] = usePersonalDetail(action.actorAccountID, displayNameOrDefaultSelector(translate, formatPhoneNumber)); const {showConfirmModal} = useConfirmModal(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const theme = useTheme(); @@ -285,7 +289,7 @@ function ReportActionItem({ navigation.setParams({reportActionID: ''}); } if (transactionIDToDismiss) { - clearErrorWithOriginalTransactionError(transactionIDToDismiss); + clearErrorWithOriginalTransactionError(transactionIDToDismiss, originalTransactionID, isOriginalTransactionSplitContainer); } clearAllRelatedReportActionErrors(reportID, action, originalReportID, isOffline); }; @@ -520,7 +524,9 @@ function ReportActionItem({ const shouldDisplayThreadReplies = shouldDisplayThreadRepliesUtils(action, isThreadReportParentAction) && !isOnSearch; const formattedTimestamp = datetimeToCalendarTime(action.created, false); - const plainMessage = getPaymentMessageWithExpectedDate(translate, dateFnsLocale, getReportActionText(action), paymentExpectedDate); + const plainMessage = isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.AGENT_PROMPT_UPDATED) + ? getAgentPromptUpdatedMessage(translate, action) + : getPaymentMessageWithExpectedDate(translate, dateFnsLocale, getReportActionText(action), paymentExpectedDate); const accessibilityLabel = `${actorDisplayName ?? ''}, ${formattedTimestamp}, ${plainMessage}`; return ( diff --git a/src/pages/inbox/report/ReportActionItemBasicMessage.tsx b/src/pages/inbox/report/ReportActionItemBasicMessage.tsx index 3dbf2f83305a..6c5eec366f25 100644 --- a/src/pages/inbox/report/ReportActionItemBasicMessage.tsx +++ b/src/pages/inbox/report/ReportActionItemBasicMessage.tsx @@ -25,7 +25,7 @@ function ReportActionItemBasicMessage({message, children}: ReportActionItemBasic const selectableStyle = !canUseTouchScreen() || !shouldUseNarrowLayout ? styles.userSelectText : styles.userSelectNone; return ( - + {!!message && (messageContainsCustomEmojiWithText ? ( id !== currentUserAccountID) ?? CONST.DEFAULT_NUMBER_ID; - const [isParticipantOptimistic = true] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: isOptimisticPersonalDetailSelector(otherParticipantAccountID)}); + const [isParticipantOptimistic = true] = usePersonalDetail(otherParticipantAccountID, isPersonalDetailOptimistic); if (!isChatReport(report)) { return null; diff --git a/src/pages/inbox/report/ReportActionItemParentAction.tsx b/src/pages/inbox/report/ReportActionItemParentAction.tsx index ce41817d3fc1..4c5504a2fb68 100644 --- a/src/pages/inbox/report/ReportActionItemParentAction.tsx +++ b/src/pages/inbox/report/ReportActionItemParentAction.tsx @@ -3,22 +3,25 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useThemeStyles from '@hooks/useThemeStyles'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {shouldExcludeAncestorReportAction} from '@libs/ReportUtils'; import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAction, ReportNameValuePairs, Transaction} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import {conciergePersonalDetailSelector, personalDetailsSelector} from '@selectors/PersonalDetails'; +import {getConciergeFeedbackForReportActionID} from '@selectors/ReportNameValuePairs'; import React from 'react'; import {View} from 'react-native'; @@ -58,8 +61,8 @@ function ReportActionItemParentAction({ const currentUserPersonalDetail = useCurrentUserPersonalDetails(); const {accountID: currentUserAccountID} = currentUserPersonalDetail; - const [conciergePersonalDetail] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: conciergePersonalDetailSelector}); - const [reportOwnerPersonalDetail] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsSelector(report?.ownerAccountID)}); + const [conciergePersonalDetail] = usePersonalDetail(CONST.ACCOUNT_ID.CONCIERGE); + const [reportOwnerPersonalDetail] = usePersonalDetail(report?.ownerAccountID); const [linkedTransactionRouteError] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { selector: (transaction: OnyxEntry) => { @@ -81,6 +84,11 @@ function ReportActionItemParentAction({ }, }); + // The backend marks the thread it opens after a thumbs down, and the answer it collects feedback on is not up for rating again + const [conciergeFeedbackForReportActionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(report?.reportID)}`, { + selector: getConciergeFeedbackForReportActionID, + }); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); @@ -127,6 +135,7 @@ function ReportActionItemParentAction({ parentReportAction={parentReportAction} transactionThreadReport={transactionThreadReport} isFirstVisibleReportAction={isFirstVisibleReportAction} + shouldAllowConciergeFeedback={!conciergeFeedbackForReportActionID} shouldUseThreadDividerLine={shouldUseThreadDividerLine} linkedTransactionRouteError={linkedTransactionRouteError} /> diff --git a/src/pages/inbox/report/ReportActionItemSingle.tsx b/src/pages/inbox/report/ReportActionItemSingle.tsx index 00eeb9b4b9b1..04044bd5f5bd 100644 --- a/src/pages/inbox/report/ReportActionItemSingle.tsx +++ b/src/pages/inbox/report/ReportActionItemSingle.tsx @@ -8,7 +8,7 @@ import Tooltip from '@components/Tooltip'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -19,7 +19,6 @@ import Navigation from '@libs/Navigation/Navigation'; import {getDelegateAccountIDFromReportAction, getHumanAgentAccountIDFromReportAction, getManagerOnVacation, getModerationFlagState, getVacationer} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Report, ReportAction} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -27,7 +26,7 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {isOptimisticPersonalDetailSelector} from '@selectors/PersonalDetails'; +import {isPersonalDetailOptimistic} from '@selectors/PersonalDetails'; import React from 'react'; import {View} from 'react-native'; @@ -122,7 +121,7 @@ function ReportActionItemSingle({ }; const optimisticCheckAccountID = delegateAccountID ? Number(delegateAccountID) : (details.accountID ?? CONST.DEFAULT_NUMBER_ID); - const [isOptimistic] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: isOptimisticPersonalDetailSelector(optimisticCheckAccountID)}); + const [isOptimistic] = usePersonalDetail(optimisticCheckAccountID, isPersonalDetailOptimistic); const shouldDisableDetailPage = CONST.RESTRICTED_ACCOUNT_IDS.includes(details.accountID ?? CONST.DEFAULT_NUMBER_ID) || (!details.isWorkspaceActor && !!isOptimistic); const getBackgroundColor = () => { diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 95693d1fd357..e240e744651e 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -26,7 +26,6 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; import { getFirstVisibleReportActionID, getLatestConciergeFeedbackActionID, - getReportActionHtml, getReportActionMessage, isConsecutiveActionMadeByPreviousActor, isDeletedParentAction, @@ -209,7 +208,11 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct hasNewerActions, }); - const persistedDraftReportAction = draftReportAction ? sortedVisibleReportActions.find((action) => action.reportActionID === draftReportAction.reportActionID) : undefined; + const persistedDraftReportAction = draftReportAction + ? (sortedAllReportActions ?? sortedVisibleReportActions).find( + (action) => action.reportActionID === draftReportAction.reportActionID && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + ) + : undefined; const renderedVisibleReportActions = (() => { if (!draftReportAction) { @@ -220,13 +223,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return sortedVisibleReportActions; } + // A completed reveal belongs to the saved action, even when pagination keeps that action off screen. + if (persistedDraftReportAction && !isDraftPendingCompletion) { + return sortedVisibleReportActions; + } + // Insert the synthetic draft into the already-descending render list without treating it as a persisted report action. for (const [index, action] of sortedVisibleReportActions.entries()) { if (action.reportActionID === draftReportAction.reportActionID) { - if (!isDraftPendingCompletion) { - return sortedVisibleReportActions; - } - const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; visibleReportActionsWithDraft[index] = draftReportAction; return visibleReportActionsWithDraft; @@ -257,10 +261,12 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct }, [clearDraft, draftReportAction, isSyntheticDraftVisible]); useEffect(() => { - if (!isDraftPendingCompletion || !draftReportAction || !persistedDraftReportAction || getReportActionHtml(draftReportAction) === getReportActionHtml(persistedDraftReportAction)) { + if (!isDraftPendingCompletion || !draftReportAction || !persistedDraftReportAction) { return; } + // The persisted action is the durable completion signal when a terminal Pusher event is missed. + // Reconcile by action ID even when its HTML is byte-identical to the last streamed draft. revealDraftFromReportAction(persistedDraftReportAction); }, [draftReportAction, isDraftPendingCompletion, persistedDraftReportAction, revealDraftFromReportAction]); @@ -340,7 +346,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct : getLatestConciergeFeedbackActionID(renderedVisibleReportActions, allReportActionIDs); useFollowActionBadgeTarget({ - isProduction, reportID, actionTargetReportActionID: reportAttributes?.actionTargetReportActionID, actionBadgeTargetIndex, diff --git a/src/pages/inbox/report/actionContents/JoinRequestContent.tsx b/src/pages/inbox/report/actionContents/JoinRequestContent.tsx index df38c77f9ecf..1ccf057d2cd7 100644 --- a/src/pages/inbox/report/actionContents/JoinRequestContent.tsx +++ b/src/pages/inbox/report/actionContents/JoinRequestContent.tsx @@ -3,6 +3,7 @@ import ActionableItemButtons from '@components/ReportActionItem/ActionableItemBu import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import {getJoinRequestMessage, getOriginalMessage} from '@libs/ReportActionsUtils'; @@ -12,7 +13,6 @@ import {acceptJoinRequest, declineJoinRequest} from '@userActions/Policy/Member' import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {personalDetailsSelector} from '@src/selectors/PersonalDetails'; import {policyNameSelector} from '@src/selectors/Policy'; import type {ReportAction} from '@src/types/onyx'; import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; @@ -31,7 +31,7 @@ function JoinRequestContent({action, actionOwnerReportID, policyID}: JoinRequest const [policyName = ''] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: policyNameSelector}); const originalMessage = getOriginalMessage(action); - const [requesterDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsSelector(originalMessage?.accountID)}); + const [requesterDetails] = usePersonalDetail(originalMessage?.accountID); const isJoinRequestUnresolved = originalMessage?.choice === ('' as JoinWorkspaceResolution); diff --git a/src/pages/inbox/report/actionContents/MemberChangeContent.tsx b/src/pages/inbox/report/actionContents/MemberChangeContent.tsx index 2d18b01037b5..c3b367fda7c9 100644 --- a/src/pages/inbox/report/actionContents/MemberChangeContent.tsx +++ b/src/pages/inbox/report/actionContents/MemberChangeContent.tsx @@ -1,5 +1,6 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail, usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -10,7 +11,6 @@ import TextCommentFragment from '@pages/inbox/report/comment/TextCommentFragment import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {personalDetailsListSelector, personalDetailsSelector} from '@src/selectors/PersonalDetails'; import type {ReportAction} from '@src/types/onyx'; import type {ValueOf} from 'type-fest'; @@ -26,8 +26,8 @@ function MemberChangeContent({action}: MemberChangeContentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const memberChangeLogReportActionMessage = isMemberChangeAction(action) ? getOriginalMessage(action) : undefined; - const [actorDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsSelector(action.actorAccountID)}); - const [targetAccountDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsListSelector(memberChangeLogReportActionMessage?.targetAccountIDs)}); + const [actorDetails] = usePersonalDetail(action.actorAccountID); + const [targetAccountDetailsList] = usePersonalDetailsByIDs(memberChangeLogReportActionMessage?.targetAccountIDs); const [memberChangeLogRoomReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${memberChangeLogReportActionMessage?.reportID}`); const derivedMemberChangeLogRoomReportName = useDerivedReportNameByReportID(memberChangeLogRoomReport?.reportID); const memberChangeLogRoomReportName = getReportName(memberChangeLogRoomReport, derivedMemberChangeLogRoomReportName) || memberChangeLogReportActionMessage?.roomName; diff --git a/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx b/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx index 028363a7aae2..47325790df4d 100644 --- a/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx +++ b/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx @@ -5,6 +5,7 @@ import ActionableItemButtons from '@components/ReportActionItem/ActionableItemBu import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {isPolicyAdmin, isPolicyMember, isPolicyOwner} from '@libs/PolicyUtils'; @@ -16,7 +17,6 @@ import {resolveActionableMentionWhisper} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {personalDetailsListSelector} from '@src/selectors/PersonalDetails'; import type {Report, ReportAction} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -48,7 +48,7 @@ function MentionWhisperContent({action, actionOwnerReportStable, parentReport, o // Subscribe to the full report here — the resolve action needs heartbeat fields for its failure-revert payload. const [actionOwnerReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${actionOwnerReportStable?.reportID}`); - const [targetAccountDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsListSelector(getOriginalMessage(action)?.inviteeAccountIDs)}); + const [targetAccountDetails] = usePersonalDetailsByIDs(getOriginalMessage(action)?.inviteeAccountIDs); const isReportInPolicy = !!policyID && policyID !== CONST.POLICY.ID_FAKE && personalPolicyID !== policyID; const hasMentionedPolicyMembers = getOriginalMessage(action)?.inviteeEmails?.every((login) => isPolicyMember(policy, login)); diff --git a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx index b35e5ce32425..22286cfd3812 100644 --- a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx +++ b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx @@ -15,6 +15,7 @@ import { getAddedConnectionMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, + getAutoCategorizeNewExpensesMessage, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, getCategoryTaxRateMessage, @@ -62,6 +63,7 @@ import { getUpdatedCardFeedLiabilityMessage, getUpdatedCardFeedStatementPeriodMessage, getUpdatedCommuterExclusionsMessage, + getUpdatedMemberWorkArrangementMessage, getUpdatedDefaultTitleMessage, getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, @@ -170,6 +172,7 @@ const POLICY_CHANGE_LOG_RESOLVERS: Record = { [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FEATURE_ENABLED]: (translate, action) => getWorkspaceFeatureEnabledMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_IS_ATTENDEE_TRACKING_ENABLED]: (translate, action) => getWorkspaceAttendeeTrackingUpdateMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REQUIRE_COMPANY_CARDS_ENABLED]: (translate, action) => getRequireCompanyCardsEnabledMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_CATEGORIZE_NEW_EXPENSES]: (translate, action) => getAutoCategorizeNewExpensesMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REQUIRES_CATEGORY]: (translate, action) => getRequiresCategoryMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REQUIRES_TAG]: (translate, action) => getRequiresTagMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_GLOBAL_REIMBURSEMENTS_FX_PREFERENCE]: (translate, action) => getCurrencyConversionFeeMessage(translate, action), @@ -230,6 +233,7 @@ const POLICY_CHANGE_LOG_RESOLVERS: Record = { getUpdatedTimeRateMessage(translate, action, convertToDisplayString), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_PROHIBITED_EXPENSES]: (translate, action) => getUpdatedProhibitedExpensesMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_COMMUTER_EXCLUSIONS]: (translate, action) => getUpdatedCommuterExclusionsMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT]: (translate, action) => getUpdatedMemberWorkArrangementMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_CHOICE]: (translate, action) => getUpdatedReimbursementChoiceMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_AUTO_JOIN]: (translate, action) => getSetAutoJoinMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE]: (translate, action) => getUpdatedDefaultTitleMessage(translate, action), diff --git a/src/pages/inbox/report/actionContents/ReimbursedContent.tsx b/src/pages/inbox/report/actionContents/ReimbursedContent.tsx index 8bd1bacbfb7b..2b11daee84b6 100644 --- a/src/pages/inbox/report/actionContents/ReimbursedContent.tsx +++ b/src/pages/inbox/report/actionContents/ReimbursedContent.tsx @@ -1,16 +1,15 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import {getReimbursedMessage} from '@libs/ReportActionsUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; -import ONYXKEYS from '@src/ONYXKEYS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type {ReportAction} from '@src/types/onyx'; +import {loginSelector} from '@selectors/PersonalDetails'; import React from 'react'; type ReimbursedContentProps = { @@ -22,8 +21,8 @@ function ReimbursedContent({action, reportOwnerAccountID}: ReimbursedContentProp const {translate, dateFnsLocale} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const [submitterLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(reportOwnerAccountID)}); - const [actorLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(action.actorAccountID)}); + const [submitterLogin] = usePersonalDetail(reportOwnerAccountID, loginSelector); + const [actorLogin] = usePersonalDetail(action.actorAccountID, loginSelector); const message = getReimbursedMessage(translate, dateFnsLocale, action, reportOwnerAccountID, submitterLogin, actorLogin, convertToDisplayString, currentUserAccountID); return ; diff --git a/src/pages/inbox/report/actionContents/ReimbursementQueuedContent.tsx b/src/pages/inbox/report/actionContents/ReimbursementQueuedContent.tsx index 2e4846bd8b75..988f28f61670 100644 --- a/src/pages/inbox/report/actionContents/ReimbursementQueuedContent.tsx +++ b/src/pages/inbox/report/actionContents/ReimbursementQueuedContent.tsx @@ -5,6 +5,7 @@ import ActionableItemButtons from '@components/ReportActionItem/ActionableItemBu import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; @@ -23,7 +24,7 @@ import type {Report, ReportAction} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {isUserValidatedSelector} from '@selectors/Account'; -import {personalDetailsDisplayNameSelector} from '@selectors/PersonalDetails'; +import {displayNameOrDefaultSelector} from '@selectors/PersonalDetails'; import {tierNameSelector} from '@selectors/UserWallet'; import React, {useContext} from 'react'; @@ -47,10 +48,7 @@ function ReimbursementQueuedContent({action, report, iouReport}: ReimbursementQu const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector}); const targetReport = isChatThread(report) ? parentReport : report; - const [ownerDisplayName] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - selector: personalDetailsDisplayNameSelector(targetReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID, translate, formatPhoneNumber), - }); - const submitterDisplayName = ownerDisplayName ?? ''; + const [submitterDisplayName = ''] = usePersonalDetail(targetReport?.ownerAccountID, displayNameOrDefaultSelector(translate, formatPhoneNumber)); const paymentType = getOriginalMessage(action)?.paymentType ?? ''; const missingPaymentMethod = getIndicatedMissingPaymentMethod(userWalletTierName, targetReport?.reportID, action, bankAccountList); diff --git a/src/pages/inbox/report/actionContents/RemovedFromApprovalChainContent.tsx b/src/pages/inbox/report/actionContents/RemovedFromApprovalChainContent.tsx index 5f9b14d4fee5..9d222fc0343c 100644 --- a/src/pages/inbox/report/actionContents/RemovedFromApprovalChainContent.tsx +++ b/src/pages/inbox/report/actionContents/RemovedFromApprovalChainContent.tsx @@ -1,12 +1,11 @@ import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import {getOriginalMessage, getRemovedFromApprovalChainMessage} from '@libs/ReportActionsUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; import type CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import {multiPersonalDetailsSelector} from '@src/selectors/PersonalDetails'; import type {PersonalDetailsList, ReportAction} from '@src/types/onyx'; @@ -26,7 +25,7 @@ function submitterNamesSelector(submittersAccountIDs: number[] | undefined) { function RemovedFromApprovalChainContent({action}: RemovedFromApprovalChainContentProps) { const {translate} = useLocalize(); - const [submitterNames] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: submitterNamesSelector(getOriginalMessage(action)?.submittersAccountIDs)}); + const [submitterNames] = useAllPersonalDetails(submitterNamesSelector(getOriginalMessage(action)?.submittersAccountIDs)); return ; } diff --git a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx index c929fca11b36..fbd937960d5d 100644 --- a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx +++ b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx @@ -1,8 +1,11 @@ +import RenderHTML from '@components/RenderHTML'; + import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import { getActionableCard3DSTransactionApprovalMessage, + getAgentPromptUpdatedMessageHTML, getDemotedFromWorkspaceMessage, getDismissedViolationMessageText, getMarkedReimbursedMessage, @@ -27,6 +30,7 @@ type SimpleMessageContentProps = { }; const SIMPLE_MESSAGE_ACTION_TYPES = new Set([ + CONST.REPORT.ACTIONS.TYPE.AGENT_PROMPT_UPDATED, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, CONST.REPORT.ACTIONS.TYPE.HOLD, CONST.REPORT.ACTIONS.TYPE.HOLD_COMMENT, @@ -37,6 +41,7 @@ const SIMPLE_MESSAGE_ACTION_TYPES = new Set([ CONST.REPORT.ACTIONS.TYPE.REOPENED, CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY, CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION, + CONST.REPORT.ACTIONS.TYPE.UNDELETED_TRANSACTION, CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION, CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES, @@ -53,6 +58,13 @@ function SimpleMessageContent({action}: SimpleMessageContentProps) { const {translate} = useLocalize(); const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.AGENT_PROMPT_UPDATED)) { + return ( + + ${getAgentPromptUpdatedMessageHTML(translate, action)}`} /> + + ); + } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) { return ; } @@ -83,6 +95,9 @@ function SimpleMessageContent({action}: SimpleMessageContentProps) { if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION)) { return ; } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.UNDELETED_TRANSACTION)) { + return ; + } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION)) { return ; } diff --git a/src/pages/inbox/report/getReportActionsToDisplay.ts b/src/pages/inbox/report/getReportActionsToDisplay.ts index 00d65b8b04fc..3e6badd1232c 100644 --- a/src/pages/inbox/report/getReportActionsToDisplay.ts +++ b/src/pages/inbox/report/getReportActionsToDisplay.ts @@ -19,6 +19,7 @@ function getReportActionsToDisplay( transactionThreadReport: OnyxTypes.Report | undefined, shouldAddCreatedAction: boolean, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + delegateAccountID: number | undefined, ) { const actions = [...(allReportActions ?? [])]; @@ -57,8 +58,7 @@ function getReportActionsToDisplay( transactionID: rand64(), iouReportID: report?.reportID, created: DateUtils.subtractMillisecondsFromDateTime(actions.at(-1)?.created ?? '', 1), - // delegateAccountIDParam: will be threaded in PR 15; buildOptimisticIOUReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) - delegateAccountIDParam: undefined, + delegateAccountIDParam: delegateAccountID, getCurrencyDecimals, }) as OnyxTypes.ReportAction; moneyRequestActions.push(optimisticIOUAction); diff --git a/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts b/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts index 5d8940ff5020..6686da21fd50 100644 --- a/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts @@ -1,7 +1,4 @@ type ShouldFollowActionBadgeTargetParams = { - /** Whether the app is running in production, where this auto-scroll behavior is gated off */ - isProduction: boolean; - /** The report action the badge currently targets (the oldest preview still requiring action) */ actionTargetReportActionID: string | undefined; @@ -24,13 +21,12 @@ type ShouldFollowActionBadgeTargetParams = { * upward/backward (e.g. when older actions are loaded in via pagination). */ function shouldFollowActionBadgeTarget({ - isProduction, actionTargetReportActionID, prevActionTargetReportActionID, actionBadgeTargetIndex, prevActionBadgeTargetIndex, }: ShouldFollowActionBadgeTargetParams): boolean { - if (isProduction || !actionTargetReportActionID || !prevActionTargetReportActionID || actionTargetReportActionID === prevActionTargetReportActionID || actionBadgeTargetIndex < 0) { + if (!actionTargetReportActionID || !prevActionTargetReportActionID || actionTargetReportActionID === prevActionTargetReportActionID || actionBadgeTargetIndex < 0) { return false; } return prevActionBadgeTargetIndex >= 0 && actionBadgeTargetIndex < prevActionBadgeTargetIndex; diff --git a/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts b/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts index 516bbddf77d0..a0f46d880404 100644 --- a/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts +++ b/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts @@ -1,4 +1,5 @@ import usePrevious from '@hooks/usePrevious'; +import useReportIDToNameMap from '@hooks/useReportIDToNameMap'; import Parser from '@libs/Parser'; import {getReportActionHtml, isDeletedAction} from '@libs/ReportActionsUtils'; @@ -28,6 +29,7 @@ function useDraftMessageVideoAttributeCache({ isEditInProgressRef, }: UseDraftMessageVideoAttributeCacheProps): DraftMessageVideoAttributeCache { const prevDraftMessage = usePrevious(draftMessage); + const reportIDToName = useReportIDToNameMap(); useEffect(() => { if (!isEditing) { @@ -37,6 +39,7 @@ function useDraftMessageVideoAttributeCache({ draftMessageVideoAttributeCache.clear(); const originalMessage = Parser.htmlToMarkdown(getReportActionHtml(editingReportAction), { + reportIDToName, cacheVideoAttributes: (videoSource, attrs) => draftMessageVideoAttributeCache.set(videoSource, attrs), }); if ( @@ -47,7 +50,7 @@ function useDraftMessageVideoAttributeCache({ return; } updateDraftMessageProp(draftMessage); - }, [draftMessage, editingReportAction, isEditInProgressRef, isEditing, prevDraftMessage, updateDraftMessageProp]); + }, [draftMessage, editingReportAction, isEditInProgressRef, isEditing, prevDraftMessage, reportIDToName, updateDraftMessageProp]); return draftMessageVideoAttributeCache; } diff --git a/src/pages/inbox/report/useFollowActionBadgeTarget.ts b/src/pages/inbox/report/useFollowActionBadgeTarget.ts index b15df8c3a2ff..bbf859b72291 100644 --- a/src/pages/inbox/report/useFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/useFollowActionBadgeTarget.ts @@ -9,9 +9,6 @@ import {useEffect, useRef} from 'react'; import shouldFollowActionBadgeTarget from './shouldFollowActionBadgeTarget'; type UseFollowActionBadgeTargetParams = { - /** Whether the app is running in production, where this auto-scroll behavior is gated off */ - isProduction: boolean; - /** The ID of the report whose list is being displayed */ reportID: string; @@ -33,7 +30,6 @@ type UseFollowActionBadgeTargetParams = { * preview requiring action. This hook scrolls down to follow it immediately on action. */ function useFollowActionBadgeTarget({ - isProduction, reportID, actionTargetReportActionID, actionBadgeTargetIndex, @@ -49,7 +45,7 @@ function useFollowActionBadgeTarget({ }); useEffect(() => { const prevActionBadgeTargetIndex = renderedVisibleReportActions.findIndex((action) => action.reportActionID === prevActionTargetReportActionID); - if (!shouldFollowActionBadgeTarget({isProduction, actionTargetReportActionID, prevActionTargetReportActionID, actionBadgeTargetIndex, prevActionBadgeTargetIndex})) { + if (!shouldFollowActionBadgeTarget({actionTargetReportActionID, prevActionTargetReportActionID, actionBadgeTargetIndex, prevActionBadgeTargetIndex})) { return; } // Only follow the badge when the resolving action happened on this report's preview while this report is the one on diff --git a/src/pages/iou/DynamicHoldReasonPage.tsx b/src/pages/iou/DynamicHoldReasonPage.tsx index 021c0d1d1c86..b1190bd52ad6 100644 --- a/src/pages/iou/DynamicHoldReasonPage.tsx +++ b/src/pages/iou/DynamicHoldReasonPage.tsx @@ -82,7 +82,17 @@ function DynamicHoldReasonPage({route}: DynamicHoldReasonPageProps) { return; } - putOnHold(transactionID, values.comment, holdReportID, isOffline, currentUserLogin ?? '', currentUserAccountID, transactionViolations, isTrackIntentUser, delegateAccountID, { + putOnHold({ + transactionID, + transaction, + comment: values.comment, + initialReportID: holdReportID, + isOffline, + currentUserLogin: currentUserLogin ?? '', + currentUserAccountID, + transactionViolations, + isTrackIntentUser, + delegateAccountID, rules, ancestors, }); diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index 473ec3b35f69..9b6b6c9aed94 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -64,6 +64,8 @@ import { isTimeRequest, } from '@libs/TransactionUtils'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -298,6 +300,9 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) return ''; }; + const isRateInteractive = !isSelfDMSplit || isRateBroken || hasAvailableEnabledRates || !hasAnyPaidWorkspace || shouldSelectPolicy; + const rateErrorText = getErrorForField('customUnitRateID'); + const distanceRequestFields = isDistance ? ( <> - { - const rateRoute = createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SPLIT_EXPENSE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID), - ); - - // SelfDM split whose source workspace is gone and user has no other paid workspace: - // mirror the selfDM track-expense Rate flow (MoneyRequestView) and route through the - // IOU-level upgrade screen so the user can create a workspace, then a distance rate. - // Use OPTIMISTIC_TRANSACTION_ID so the post-upgrade hop back into the rate step picks - // up the same SPLIT_TRANSACTION_DRAFT this screen reads from (see line 57 above). - if (isSelfDMSplit && !effectivePolicy && !hasAnyPaidWorkspace && reportID) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType: CONST.IOU.TYPE.SPLIT_EXPENSE, - transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID, - reportID, - upgradePath: CONST.UPGRADE_PATHS.DISTANCE_RATES, - }), - ), - ); - return; - } - - // SelfDM split with paid workspaces but none is default/active paid (e.g. personal - // is the active policy): open the workspace selector first — same UX as the parent - // self-DM expense's Rate field in MoneyRequestView and the Category branch below. - if (!effectivePolicy && shouldSelectPolicy) { - Navigation.navigate(ROUTES.SET_DEFAULT_WORKSPACE.getRoute(rateRoute)); - return; - } - - Navigation.navigate(rateRoute); - }} - /> + { + const rateRoute = createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute( + CONST.IOU.ACTION.EDIT, + CONST.IOU.TYPE.SPLIT_EXPENSE, + CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + reportID, + ), + ); + + // SelfDM split whose source workspace is gone and user has no other paid workspace: + // mirror the selfDM track-expense Rate flow (MoneyRequestView) and route through the + // IOU-level upgrade screen so the user can create a workspace, then a distance rate. + // Use OPTIMISTIC_TRANSACTION_ID so the post-upgrade hop back into the rate step picks + // up the same SPLIT_TRANSACTION_DRAFT this screen reads from (see line 57 above). + if (isSelfDMSplit && !effectivePolicy && !hasAnyPaidWorkspace && reportID) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType: CONST.IOU.TYPE.SPLIT_EXPENSE, + transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + reportID, + upgradePath: CONST.UPGRADE_PATHS.DISTANCE_RATES, + }), + ), + ); + return; + } + + // SelfDM split with paid workspaces but none is default/active paid (e.g. personal + // is the active policy): open the workspace selector first — same UX as the parent + // self-DM expense's Rate field in MoneyRequestView and the Category branch below. + if (!effectivePolicy && shouldSelectPolicy) { + Navigation.navigate(ROUTES.SET_DEFAULT_WORKSPACE.getRoute(rateRoute)); + return; + } + + Navigation.navigate(rateRoute); + }) + : undefined + } + > + + {!!rateErrorText && } + {isRateInteractive && } + + {!!rateErrorText && ( + + )} + ) : null; diff --git a/src/pages/iou/DynamicSplitExpensePage.tsx b/src/pages/iou/DynamicSplitExpensePage.tsx index 55ac81803984..36dcdb3acee3 100644 --- a/src/pages/iou/DynamicSplitExpensePage.tsx +++ b/src/pages/iou/DynamicSplitExpensePage.tsx @@ -5,7 +5,7 @@ import FormHelpMessage from '@components/FormHelpMessage'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ScreenWrapper from '@components/ScreenWrapper'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; import type {SplitListItemType} from '@components/SelectionList/ListItem/types'; @@ -583,16 +583,11 @@ function DynamicSplitExpensePage({route}: DynamicSplitExpensePageProps) { const headerDateContent = ( - ); diff --git a/src/pages/iou/SplitList/index.tsx b/src/pages/iou/SplitList/index.tsx index bdcefc554ce1..73e7c0d19a82 100644 --- a/src/pages/iou/SplitList/index.tsx +++ b/src/pages/iou/SplitList/index.tsx @@ -1,6 +1,6 @@ import SelectionList from '@components/SelectionList'; import SplitListItem from '@components/SelectionList/ListItem/SplitListItem'; -import type {ListItem, SplitListItemType} from '@components/SelectionList/ListItem/types'; +import type {SplitListItemType} from '@components/SelectionList/ListItem/types'; import type {SelectionListHandle} from '@components/SelectionList/types'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -9,7 +9,7 @@ import type CONST from '@src/CONST'; import type {ValueOf} from 'type-fest'; -import React, {useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; import useHandleInputFocus from './useHandleInputFocus'; @@ -30,8 +30,6 @@ type SplitListProps = { mode: ValueOf; }; -type SplitListItemProps = React.ComponentProps; - /** * Unified component for split expense tabs (Amount, Percentage, Date). * Renders split items with the appropriate input type based on mode, @@ -43,19 +41,7 @@ function SplitList({data, initiallyFocusedOptionKey, onSelectRow, listFooterCont const handleInputFocus = useHandleInputFocus({listRef}); - // Create a wrapper component that adds the onInputFocus handler - const SplitListItemWithInputFocus = useMemo( - () => - ((props: SplitListItemProps) => ( - handleInputFocus(item as SplitListItemType)} - {...props} - /> - )) as typeof SplitListItem, - [handleInputFocus], - ); - - const splitOptions = data.map((option) => ({...option, mode})); + const splitOptions = data.map((option) => ({...option, mode, onInputFocus: handleInputFocus})); return ( doesPersonalDetailMatchSearchTerm(participant, currentUserAccountID, cleanSearchTerm)), + participants.some((participant) => doesPersonalDetailMatchSearchTerm(participant, currentUserAccountID, cleanSearchTerm, translate)), ); const showImportContacts = diff --git a/src/pages/iou/request/step/AmountSubmitDataSync.tsx b/src/pages/iou/request/step/AmountSubmitDataSync.tsx index fe1b3be66791..2a1a4b396838 100644 --- a/src/pages/iou/request/step/AmountSubmitDataSync.tsx +++ b/src/pages/iou/request/step/AmountSubmitDataSync.tsx @@ -3,6 +3,7 @@ import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import useSelfDMReport from '@hooks/useSelfDMReport'; @@ -80,7 +81,7 @@ function AmountSubmitDataSync({report, transaction, transactionID, policyID, isE const selfDMReport = useSelfDMReport(); const defaultExpensePolicy = useDefaultExpensePolicy(); const personalPolicy = usePersonalPolicy(); - const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [allPersonalDetails] = useAllPersonalDetails(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allReportDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT); const [allReportNVPs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); diff --git a/src/pages/iou/request/step/DynamicIOURequestEditReport.tsx b/src/pages/iou/request/step/DynamicIOURequestEditReport.tsx index a18380a8e95d..4cdc6d5e84c3 100644 --- a/src/pages/iou/request/step/DynamicIOURequestEditReport.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestEditReport.tsx @@ -105,6 +105,7 @@ function DynamicIOURequestEditReport({route}: DynamicIOURequestEditReportProps) const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReportID)}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); const selectReport = (item: TransactionGroupListItem, report?: OnyxEntry) => { if (transactionIDs.length === 0 || item.value === reportID) { @@ -131,6 +132,7 @@ function DynamicIOURequestEditReport({route}: DynamicIOURequestEditReportProps) allTransactionViolation: transactionViolations, reports: reportsForCall, rules, + cardList, isTrackIntentUser, personalPolicyOutputCurrency: personalPolicy?.outputCurrency, selfDMReportActions, @@ -162,6 +164,7 @@ function DynamicIOURequestEditReport({route}: DynamicIOURequestEditReportProps) allTransactionViolation: transactionViolations, reports, rules, + cardList, isTrackIntentUser, personalPolicyOutputCurrency: personalPolicy?.outputCurrency, selfDMReportActions, diff --git a/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx b/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx index 9309efade426..0d3fcae34691 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx @@ -7,6 +7,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePrevious from '@hooks/usePrevious'; import useReportOwnerAsAttendee from '@hooks/useReportOwnerAsAttendee'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; @@ -23,10 +24,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type {Attendee} from '@src/types/onyx/IOU'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {loginSelector} from '@selectors/PersonalDetails'; import {deepEqual} from 'fast-equals'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -55,7 +56,7 @@ function DynamicIOURequestStepAttendees({ const reportOwnerAsAttendee = useReportOwnerAsAttendee(transaction); const [attendees, setAttendees] = useState(() => getOriginalAttendees(transaction, reportOwnerAsAttendee)); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); + const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx b/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx index f83e2954f947..d5b747707863 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx @@ -3,14 +3,10 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOffli import Button from '@components/Button'; import CategoryPicker from '@components/CategoryPicker'; import FixedFooter from '@components/FixedFooter'; -import {useSearchQueryContext} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionList/types'; import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; -import useAllTransactionViolations from '@hooks/useAllTransactionViolations'; -import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -24,13 +20,11 @@ import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; +import useUpdateTransactionCategory from '@hooks/useUpdateTransactionCategory'; -import {getIOURequestPolicyID, setMoneyRequestCategory} from '@libs/actions/IOU/MoneyRequest'; -import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; -import {updateMoneyRequestCategory} from '@libs/actions/IOU/UpdateMoneyRequest'; +import {getIOURequestPolicyID} from '@libs/actions/IOU/MoneyRequest'; import {enablePolicyCategories, getPolicyCategories} from '@libs/actions/Policy/Category'; import {isCategoryMissing} from '@libs/CategoryUtils'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getSelectedWorkspacePolicyID, pickReportForPolicy} from '@libs/IOUUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -43,9 +37,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import lodashIsEmpty from 'lodash/isEmpty'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -68,7 +60,6 @@ function DynamicIOURequestStepCategory({ }, transaction, }: DynamicIOURequestStepCategoryProps) { - const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); const styles = useThemeStyles(); const {translate} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['EmptyStateExpenses']); @@ -95,32 +86,17 @@ function DynamicIOURequestStepCategory({ const policyID = policy?.id; const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); - const allTransactionViolations = useAllTransactionViolations(transaction?.transactionID); const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyIdDraft}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); - const [policyRecentlyUsedCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); - const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, { - selector: isTrackIntentUserSelector, - }); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const policyCategories = policyCategoriesReal ?? policyCategoriesDraft; const policyData = usePolicyData(policy?.id); - const {currentSearchHash} = useSearchQueryContext(); const currentTransaction = isEditingSplit && !lodashIsEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction; const transactionCategory = getTransactionDetails(currentTransaction)?.category ?? ''; useRestartOnReceiptFailure(transaction, routeReportID, iouType, action); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const currentUserAccountIDParam = currentUserPersonalDetails.accountID; - const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const delegateAccountID = useDelegateAccountID(); - const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); + const currentUserEmailParam = useCurrentUserPersonalDetails().login ?? ''; + const {isBetaEnabledOrUnknown} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const categoryForDisplay = isCategoryMissing(transactionCategory) ? '' : transactionCategory; @@ -167,6 +143,16 @@ function DynamicIOURequestStepCategory({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [policyID]); + const {updateCategory: updateTransactionCategory, isDraftUpdate} = useUpdateTransactionCategory({ + transactionID, + transaction, + report, + policy, + policyCategories, + isEditing, + isEditingSplit, + }); + const navigateBack = () => { Navigation.goBack(backPath); }; @@ -180,54 +166,11 @@ function DynamicIOURequestStepCategory({ const isSelectedCategory = categorySearchText === categoryForDisplay; const updatedCategory = isSelectedCategory ? '' : categorySearchText; - if (transaction) { - // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value - if (isEditingSplit) { - setDraftSplitTransaction(transaction.transactionID, splitDraftTransaction, {category: updatedCategory}, getCurrencyDecimals, getCurrencySymbol, policy); - saveAndNavigateBack(); - return; - } - - if (isEditing && report) { - updateMoneyRequestCategory({ - isVendorMatchingBetaEnabled, - transactionID: transaction.transactionID, - transaction, - transactionThreadReport: report, - parentReport, - iouReportOwnerLogin, - category: updatedCategory, - policy, - policyTagList: policyTags, - policyCategories, - policyRecentlyUsedCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - hash: currentSearchHash, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - violations: allTransactionViolations, - getCurrencyDecimals, - getCurrencySymbol, - rules, - }); - saveAndNavigateBack(); - return; - } - } - - setMoneyRequestCategory(transactionID, updatedCategory, policy, getCurrencyDecimals); - - // `action === CATEGORIZE` only ever occurs when categorizing a fresh tracked expense directly from a - // report (never from an existing Confirmation screen), so continue forward into Confirmation here. - if (action === CONST.IOU.ACTION.CATEGORIZE) { - if (backPath.includes('/confirmation/')) { - saveAndNavigateBack(); - return; - } + updateTransactionCategory(updatedCategory); + // `action === CATEGORIZE` only occurs when categorizing a fresh tracked expense from a report, never from + // an existing Confirmation screen, so continue forward into Confirmation here. + if (isDraftUpdate && action === CONST.IOU.ACTION.CATEGORIZE && !backPath.includes('/confirmation/')) { if (report?.reportID) { Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, report.reportID)); } diff --git a/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx b/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx index 2709c0324391..ecd7cd0c9c76 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx @@ -10,6 +10,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; @@ -25,6 +26,7 @@ import getPathWithoutDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesU import Navigation from '@libs/Navigation/Navigation'; import {hasTags} from '@libs/PolicyUtils'; import {isSelfDM} from '@libs/ReportUtils'; +import StringUtils from '@libs/StringUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CategoryForm from '@pages/workspace/categories/CategoryForm'; @@ -33,9 +35,9 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {loginSelector} from '@selectors/PersonalDetails'; import React from 'react'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; @@ -98,7 +100,7 @@ function DynamicIOURequestStepCategoryCreate({ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const [policyRecentlyUsedCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportReal?.parentReportID ?? reportDraft?.parentReportID)}`); - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); + const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); @@ -124,7 +126,7 @@ function DynamicIOURequestStepCategoryCreate({ } = useOnboardingTaskInformation(CONST.ONBOARDING_TASK_TYPE.SETUP_CATEGORIES_AND_TAGS); const createCategory = (values: FormOnyxValues) => { - const categoryName = values.categoryName.trim(); + const categoryName = StringUtils.sanitizeName(values.categoryName); if (!policyID) { return; diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx index 78a0395c2593..883822bbd2c6 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx @@ -13,6 +13,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -35,13 +36,13 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import INPUT_IDS from '@src/types/form/MoneyRequestDateForm'; import type {Report, Transaction} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {loginSelector} from '@selectors/PersonalDetails'; import lodashIsEmpty from 'lodash/isEmpty'; import React, {useCallback} from 'react'; @@ -83,7 +84,7 @@ function DynamicIOURequestStepDate({ const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); + const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx index 95541661cb02..43bbdbc23225 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx @@ -14,6 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import useNavigateBackOnSave from '@hooks/useNavigateBackOnSave'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; @@ -37,13 +38,13 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import INPUT_IDS from '@src/types/form/MoneyRequestDescriptionForm'; import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {loginSelector} from '@selectors/PersonalDetails'; import lodashIsEmpty from 'lodash/isEmpty'; import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; @@ -73,7 +74,7 @@ function DynamicIOURequestStepDescription({ const allTransactionViolations = useAllTransactionViolations(transaction?.transactionID); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); + const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDistanceRate.tsx index faaa85d8d49b..33e77e963418 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDistanceRate.tsx @@ -12,6 +12,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; @@ -40,12 +41,12 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {loginSelector} from '@selectors/PersonalDetails'; import {policyTypeSelector} from '@selectors/Policy'; import lodashIsEmpty from 'lodash/isEmpty'; import React, {useState} from 'react'; @@ -77,7 +78,7 @@ function DynamicIOURequestStepDistanceRate({ const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); + const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx b/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx index a20efa9e1410..4e626a5fe607 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx @@ -15,6 +15,7 @@ import useNavigateBackOnSave from '@hooks/useNavigateBackOnSave'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; @@ -35,11 +36,11 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import INPUT_IDS from '@src/types/form/MoneyRequestMerchantForm'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {loginSelector} from '@selectors/PersonalDetails'; import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; @@ -67,7 +68,7 @@ function DynamicIOURequestStepMerchant({ const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); + const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const styles = useThemeStyles(); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx index 06d47afc7561..a2ad9156bdc1 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx @@ -10,10 +10,12 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLoadPolicyTags from '@hooks/useLoadPolicyTags'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; @@ -36,10 +38,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {loginSelector} from '@selectors/PersonalDetails'; import React, {useMemo} from 'react'; import {View} from 'react-native'; @@ -84,7 +86,7 @@ function DynamicIOURequestStepTag({ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const [policyRecentlyUsedTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); + const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -141,7 +143,15 @@ function DynamicIOURequestStepTag({ return parentTagAtIndex ? [parentTagAtIndex] : undefined; }, [parentTransactionTag, tagListIndex]); - const shouldShowTag = transactionTag || hasEnabledTags(policyTagLists) || !!additionalTagsToInclude?.length; + // Backfill the policy's tags here too, not just inside TagPicker: on a lazy-loaded account an absent collection + // makes `hasEnabledTags` false, so without this the empty state renders, TagPicker never mounts, and the read it + // owns never fires — leaving the user unable to assign a tag to an untagged expense. + const {isLoadingPolicyTags} = useLoadPolicyTags(policyID); + + // An absent tags collection means "not read yet", not "this policy has no tags", so keep the picker mounted while + // the read is in flight and let it show its own loading state. Once the tags land, a genuinely tag-less policy + // falls back to the empty state as before. + const shouldShowTag = transactionTag || hasEnabledTags(policyTagLists) || !!additionalTagsToInclude?.length || isLoadingPolicyTags; const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx index 81c8f6c764c4..525482ef62ff 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx @@ -7,6 +7,7 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; @@ -26,7 +27,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type {Policy, Transaction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -34,6 +34,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useFocusEffect} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {loginSelector} from '@selectors/PersonalDetails'; import React, {useCallback, useRef} from 'react'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -76,7 +77,7 @@ function DynamicIOURequestStepTaxAmountPage({ const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); + const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepTaxRatePage.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTaxRatePage.tsx index 150044b77c53..31e4abda1473 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTaxRatePage.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTaxRatePage.tsx @@ -8,6 +8,7 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; @@ -28,13 +29,13 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type {Policy, Transaction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {loginSelector} from '@selectors/PersonalDetails'; import React from 'react'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -81,7 +82,7 @@ function DynamicIOURequestStepTaxRatePage({ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); + const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); useRestartOnReceiptFailure(transaction, reportIDFromRoute, iouType, action); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx index 9c35a067b3a1..cebe3dca5442 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx @@ -111,6 +111,7 @@ function DynamicIOURequestStepUpgrade({ const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReportID)}`); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); // Search-selected transactions are not in COLLECTION.TRANSACTION — extract from `selectedTransactions` directly. @@ -174,6 +175,7 @@ function DynamicIOURequestStepUpgrade({ allTransactionViolation: transactionViolations, reports: reportsForCall, rules, + cardList, selfDMReportActions, isTrackIntentUser, // Expenses move to the upgraded workspace (newPolicy), whose currency drives any distance calculation, so the personal-policy currency is never read here. @@ -294,6 +296,7 @@ function DynamicIOURequestStepUpgrade({ getCurrencyDecimals, getCurrencySymbol, rules, + cardList, areAllMatchingItemsSelected, currentSearchQueryJSON, excludedTransactions, diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index ee6b962db838..d3aa0ed49d3f 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -439,7 +439,7 @@ function IOURequestEditReportCommon({ = CONST.STANDARD_LIST_ITEM_LIMIT} textInputOptions={{ value: searchValue, diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 5bc8ab15358b..e1d36dec11d9 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -66,7 +66,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; -import {findSelfDMReportID, generateReportID, getChatByParticipants, getReportOrDraftReport, isMoneyRequestReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; +import {findSelfDMReportID, generateReportID, getParticipantsChatKey, getReportOrDraftReport, isMoneyRequestReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import {cancelTracking, getPendingSubmitFollowUpAction, isTracking} from '@libs/telemetry/submitFollowUpAction'; import { getRequestType, @@ -94,7 +94,7 @@ import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {Policy} from '@src/types/onyx'; +import type {OneOnOneChatReportIDsDerivedValue, Policy} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {Receipt} from '@src/types/onyx/Transaction'; @@ -723,11 +723,11 @@ function IOURequestStepConfirmationContent({ // Read reports reactively: if the chat lands mid-flow the optimistic ID must drop out, or we'd reveal an uncreated report. const existingP2PChatSelector = useCallback( - (reports: Parameters[1]) => - isP2PDestination ? getChatByParticipants([p2pRecipientAccountID, currentUserPersonalDetails.accountID], reports)?.reportID : undefined, + (chatReportIDs: OnyxEntry) => + isP2PDestination ? chatReportIDs?.reportIDs?.[getParticipantsChatKey([p2pRecipientAccountID, currentUserPersonalDetails.accountID])] : undefined, [isP2PDestination, p2pRecipientAccountID, currentUserPersonalDetails.accountID], ); - const [existingP2PDestinationReportID] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: existingP2PChatSelector}); + const [existingP2PDestinationReportID] = useOnyx(ONYXKEYS.DERIVED.ONE_ON_ONE_CHAT_REPORT_IDS, {selector: existingP2PChatSelector}); const optimisticP2PDestinationReportID = !existingP2PDestinationReportID && reusableP2PReportID ? reusableP2PReportID : undefined; // Trust `report` when it already belongs to this participant (their chat, or an IOU report under it), so a // flow started from an IOU report keeps that report as destination instead of falling back to the chat. diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/GPSButtons/index.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/GPSButtons/index.tsx index 19d7f9bed52b..ea591ee07807 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/GPSButtons/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/GPSButtons/index.tsx @@ -1,8 +1,9 @@ import Button from '@components/Button'; -import ConfirmModal from '@components/ConfirmModal'; import {loadIllustration} from '@components/Icon/IllustrationLoader'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import {useSession} from '@components/OnyxListItemProvider'; +import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyAsset} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -22,7 +23,7 @@ import type {GPSPoint} from '@src/types/onyx/GpsDraftDetails'; import type {Unit} from '@src/types/onyx/Policy'; import {hasServicesEnabledAsync, startLocationUpdatesAsync} from 'expo-location'; -import React, {useState} from 'react'; +import React, {useRef} from 'react'; import {Linking, View} from 'react-native'; import GPSTooltip from './GPSTooltip'; @@ -49,11 +50,9 @@ type ButtonsProps = { }; function GPSButtons({navigateToNextStep, setShouldShowStartError, setShouldShowPermissionsError, reportID, unit, gpsPoints}: ButtonsProps) { - const [showLocationRequiredModal, setShowLocationRequiredModal] = useState(false); - const [showZeroDistanceModal, setShowZeroDistanceModal] = useState(false); - const [showDisabledServicesModal, setShowDisabledServicesModal] = useState(false); const {isOffline} = useNetwork(); const session = useSession(); + const {showConfirmModal} = useConfirmModal(); const {asset: ReceiptLocationMarker} = useMemoizedLazyAsset(() => loadIllustration('ReceiptLocationMarker')); const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); @@ -62,6 +61,69 @@ function GPSButtons({navigateToNextStep, setShouldShowStartError, setShouldShowP const isTripStopped = isTripStoppedUtil(gpsDraftDetails); + // The prompts live on the global modal stack, which adds a new entry on every call. Two quick presses can both get + // past the awaited services check before either prompt is up, so this keeps a second prompt from stacking on the first. + const isPromptOpenRef = useRef(false); + + const showPrompt = (options: Parameters[0], onConfirm?: () => void) => { + if (isPromptOpenRef.current) { + return; + } + isPromptOpenRef.current = true; + + showConfirmModal(options).then((result) => { + isPromptOpenRef.current = false; + + if (result.action !== ModalActions.CONFIRM) { + return; + } + + onConfirm?.(); + }); + }; + + const showDisabledServicesModal = () => { + showPrompt( + { + title: translate('gps.locationServicesRequiredModal.title'), + prompt: translate('gps.locationServicesRequiredModal.prompt'), + confirmText: translate('gps.locationServicesRequiredModal.confirm'), + cancelText: translate('common.dismiss'), + shouldReverseStackedButtons: true, + }, + openSettings, + ); + }; + + const showLocationRequiredModal = () => { + showPrompt( + { + title: translate('gps.locationRequiredModal.title'), + prompt: translate('gps.locationRequiredModal.prompt'), + confirmText: translate('common.settings'), + cancelText: translate('common.dismiss'), + iconSource: ReceiptLocationMarker, + iconFill: false, + iconWidth: 140, + iconHeight: 120, + shouldCenterIcon: true, + shouldReverseStackedButtons: true, + }, + () => { + Linking.openSettings(); + }, + ); + }; + + const showZeroDistanceModal = () => { + showPrompt({ + title: translate('gps.zeroDistanceTripModal.title'), + prompt: translate('gps.zeroDistanceTripModal.prompt'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }; + // Returns true if location tracking was successfully initialized, false otherwise const initLocationTracking = async (): Promise => { try { @@ -99,7 +161,7 @@ function GPSButtons({navigateToNextStep, setShouldShowStartError, setShouldShowP const startPermissionsFlow = useBackgroundLocationPermissionsFlow({ // eslint-disable-next-line @typescript-eslint/no-misused-promises -- the flow starts the trip without awaiting it onGrant: isTripStopped ? resumeGpsTrip : startGpsTrip, - onDeny: () => setShowLocationRequiredModal(true), + onDeny: showLocationRequiredModal, onError: () => setShouldShowPermissionsError(true), }); @@ -109,7 +171,7 @@ function GPSButtons({navigateToNextStep, setShouldShowStartError, setShouldShowP const hasLocationServicesEnabled = await hasServicesEnabledAsync(); if (!hasLocationServicesEnabled) { - setShowDisabledServicesModal(true); + showDisabledServicesModal(); return; } @@ -122,96 +184,50 @@ function GPSButtons({navigateToNextStep, setShouldShowStartError, setShouldShowP const saveGpsTrip = () => { if (gpsDraftDetails?.distanceInMeters === 0) { - setShowZeroDistanceModal(true); + showZeroDistanceModal(); return; } navigateToNextStep(); }; - const openSettingsForLocationServices = () => { - setShowDisabledServicesModal(false); - openSettings(); - }; - - return ( - <> - {isTripStopped ? ( - - - - - ) : ( - - - - - - )} - - setShowZeroDistanceModal(false)} - confirmText={translate('common.buttonConfirm')} - prompt={translate('gps.zeroDistanceTripModal.prompt')} - /> - { - setShowLocationRequiredModal(false); - Linking.openSettings(); - }} - onCancel={() => setShowLocationRequiredModal(false)} - confirmText={translate('common.settings')} - cancelText={translate('common.dismiss')} - prompt={translate('gps.locationRequiredModal.prompt')} - iconSource={ReceiptLocationMarker} - iconFill={false} - iconWidth={140} - iconHeight={120} - shouldCenterIcon - shouldReverseStackedButtons - /> - setShowDisabledServicesModal(false)} - confirmText={translate('gps.locationServicesRequiredModal.confirm')} - cancelText={translate('common.dismiss')} - prompt={translate('gps.locationServicesRequiredModal.prompt')} - shouldReverseStackedButtons - /> - + return isTripStopped ? ( + + + + + ) : ( + + + + + ); } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx index 1457edbc41a3..96f1f205f212 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx @@ -12,6 +12,7 @@ import useMoneyRequestPolicyTagsForReport from '@hooks/useMoneyRequestPolicyTags import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -73,7 +74,7 @@ function IOURequestStepDistanceGPS({ const isArchived = useReportIsArchived(report?.reportID); const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [personalDetails] = useAllPersonalDetails(); const reportAttributesDerived = useReportAttributes(); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); diff --git a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts index 16eaf551212f..cd18b4c2b28e 100644 --- a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts +++ b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts @@ -120,6 +120,7 @@ function useReportSelectionActions({ const reports = useChangeTransactionsReportReports(transaction ? [transaction] : [], undefined); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const delegateAccountID = useDelegateAccountID(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -218,6 +219,7 @@ function useReportSelectionActions({ allTransactionViolation: transactionViolations, reports: reportsForCall, rules, + cardList, isTrackIntentUser, personalPolicyOutputCurrency: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${personalPolicyID}`]?.outputCurrency, selfDMReportActions, @@ -250,6 +252,7 @@ function useReportSelectionActions({ allTransactionViolation: transactionViolations, reports, rules, + cardList, isTrackIntentUser, personalPolicyOutputCurrency: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${personalPolicyID}`]?.outputCurrency, selfDMReportActions, diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index 21d5094fa50b..1d015e201149 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -12,6 +12,7 @@ import useOnyx from '@hooks/useOnyx'; import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; import useParticipantsPolicyTags from '@hooks/useParticipantsPolicyTags'; import usePermissions from '@hooks/usePermissions'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePreMountDestination from '@hooks/usePreMountDestination'; @@ -90,7 +91,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, const delegateAccountID = useDelegateAccountID(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [personalDetails] = useAllPersonalDetails(); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 02971cbd38a2..0d2ddc3705f0 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -175,8 +175,15 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const [sourceUri, setSourceUri] = useState(''); const parentReportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); - const canEditReceipt = canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction, rules}); - const canDeleteReceipt = canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, isDeleteAction: true, transaction, rules}); + const canEditReceipt = canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction, rules, reportNameValuePairs: undefined}); + const canDeleteReceipt = canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + isDeleteAction: true, + transaction, + rules, + reportNameValuePairs: undefined, + }); const receiptFilename = transaction?.receipt?.filename; const isStitchedOdometerReceipt = isOdometerDistanceRequest(transaction) && !imageType; diff --git a/src/pages/settings/AboutPage/AboutPage.tsx b/src/pages/settings/AboutPage/AboutPage.tsx index 835ac71d9da3..3b1c6f495f5a 100644 --- a/src/pages/settings/AboutPage/AboutPage.tsx +++ b/src/pages/settings/AboutPage/AboutPage.tsx @@ -11,6 +11,7 @@ import useDocumentTitle from '@hooks/useDocumentTitle'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; @@ -75,6 +76,7 @@ function AboutPage() { const aboutIllustration = useAboutSectionIllustration(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -119,7 +121,7 @@ function AboutPage() { icon: icons.Bug, sentryLabel: CONST.SENTRY_LABEL.SETTINGS_ABOUT.REPORT_A_BUG, action: waitForNavigate(() => { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: false}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); }), }, ]; @@ -146,7 +148,7 @@ function AboutPage() { wrapperStyle: [styles.sectionMenuItemTopDescription], sentryLabel, })); - }, [icons, styles, translate, waitForNavigate, conciergeReportID, introSelected, isSelfTourViewed, currentUserAccountID]); + }, [icons, styles, translate, waitForNavigate, conciergeReportID, introSelected, isSelfTourViewed, currentUserAccountID, conciergePersonalDetails]); const overlayContent = useCallback( () => ( diff --git a/src/pages/settings/Agents/AddAgentPage.tsx b/src/pages/settings/Agents/AddAgentPage.tsx index 00cf4179d60e..d9bc458715ac 100644 --- a/src/pages/settings/Agents/AddAgentPage.tsx +++ b/src/pages/settings/Agents/AddAgentPage.tsx @@ -43,9 +43,9 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {TextInputKeyPressEvent} from 'react-native'; import React, {useCallback, useEffect, useRef} from 'react'; -import {View} from 'react-native'; +import {Platform, View} from 'react-native'; -import {PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE} from './const'; +import {PROMPT_MAX_AUTO_GROW_HEIGHT, PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE} from './const'; import scrollToMultilineInput from './scrollToMultilineInput'; type AddAgentPageProps = PlatformStackScreenProps; @@ -66,6 +66,10 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { const {windowWidth, windowHeight} = useWindowDimensions(); const {isKeyboardActive} = useKeyboardState(); const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); + // On native portrait the prompt grows with its content up to a max height instead of filling the screen, so the open + // keyboard can't squeeze it or hide the line being edited, and the form scrolls to fit it above the keyboard. + const shouldAutoGrowPromptInput = Platform.OS !== 'web' && !isInLandscapeMode; + const shouldUseScrollableLayout = shouldAutoGrowPromptInput || isInLandscapeMode; const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive; const {accountID: ownerAccountID, login: ownerLogin, displayName} = useCurrentUserPersonalDetails(); const defaultAgentName = template?.name ?? (displayName ? translate('addAgentPage.defaultAgentName', displayName) : undefined); @@ -182,7 +186,7 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { }; const promptTopOffsetRef = useRef(0); - const handleInputFocus = () => scrollToMultilineInput(formRef, isInLandscapeMode, promptTopOffsetRef.current); + const handleInputFocus = () => scrollToMultilineInput(formRef, shouldUseScrollableLayout, promptTopOffsetRef.current); const agentAvatar = avatarSource ? ( { promptTopOffsetRef.current = event.nativeEvent.layout.y; }} @@ -256,9 +265,11 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { onKeyPress={submitFormOnModEnter} defaultValue={defaultPrompt} multiline - containerStyles={[styles.h100]} - touchableInputWrapperStyle={[styles.flex1]} - inputStyle={[styles.flex1, styles.textAlignVerticalTop]} + autoGrowHeight={shouldAutoGrowPromptInput} + maxAutoGrowHeight={shouldAutoGrowPromptInput ? PROMPT_MAX_AUTO_GROW_HEIGHT : undefined} + containerStyles={shouldAutoGrowPromptInput ? undefined : [styles.h100]} + touchableInputWrapperStyle={shouldAutoGrowPromptInput ? undefined : [styles.flex1]} + inputStyle={[!shouldAutoGrowPromptInput && styles.flex1, styles.textAlignVerticalTop]} onFocus={handleInputFocus} /> diff --git a/src/pages/settings/Agents/EditAgentPage.tsx b/src/pages/settings/Agents/EditAgentPage.tsx index 0adc52eed325..1a950b1497a2 100644 --- a/src/pages/settings/Agents/EditAgentPage.tsx +++ b/src/pages/settings/Agents/EditAgentPage.tsx @@ -3,7 +3,7 @@ import AvatarButtonWithIcon from '@components/AvatarButtonWithIcon'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemFieldHTML from '@components/MenuItem/presets/MenuItemFieldHTML'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -14,6 +14,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; import useSwitchToDelegator from '@hooks/useSwitchToDelegator'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -23,6 +24,7 @@ import {getRuleBotEnforcedPolicy} from '@libs/AgentRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import Parser from '@libs/Parser'; import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -44,14 +46,15 @@ function EditAgentPage({route}: EditAgentPageProps) { const icons = useMemoizedLazyExpensifyIcons(['Trashcan', 'ChatBubble', 'MagnifyingGlass', 'Users']); const accountID = route.params.accountID; const [agent, agentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); - const [personalDetails, personalDetailsMetadata] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (list) => list?.[accountID]}); + const [personalDetails, personalDetailsMetadata] = usePersonalDetail(accountID); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {showConfirmModal} = useConfirmModal(); const showRuleBotGuardModal = useRuleBotGuardModal(); const chatWithAgent = useChatWithAgent(); const switchToDelegator = useSwitchToDelegator(); const isOnyxLoaded = agentMetadata.status === 'loaded' && personalDetailsMetadata.status === 'loaded'; - const shouldShowNotFoundPage = isOnyxLoaded && !agent && !personalDetails; + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const shouldShowNotFoundPage = isLoadingApp === false && isOnyxLoaded && !agent && !personalDetails; const agentLogin = personalDetails?.login ?? ''; const handleBackPress = () => Navigation.goBack(); @@ -79,6 +82,7 @@ function EditAgentPage({route}: EditAgentPageProps) { }; const isPendingAddOrDelete = agent?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD || agent?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const areActionsDisabled = isPendingAddOrDelete || accountID <= 0 || !agentLogin; + const agentPrompt = Str.htmlDecode(agent?.prompt?.trim() ?? ''); const handleChatPress = () => { chatWithAgent(accountID); }; @@ -148,14 +152,10 @@ function EditAgentPage({route}: EditAgentPageProps) { errorRowStyles={[styles.mh5, styles.mb2]} onClose={() => clearAgentPromptUpdateError(accountID)} > - diff --git a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx index c126468589b0..14443666d669 100644 --- a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx +++ b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx @@ -13,7 +13,7 @@ import useAvatarCrop from '@hooks/useAvatarCrop'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -30,7 +30,6 @@ import {updateAgentAvatar} from '@userActions/Agent'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -66,7 +65,7 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset const icons = useMemoizedLazyExpensifyIcons(['Upload']); const presetAvatarSize = StyleUtils.getAvatarSize(CONST.AVATAR_SIZE.X_LARGE); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (list) => list?.[accountID]}); + const [personalDetails] = usePersonalDetail(accountID); const initialBotAvatar = useMemo(() => { if (!initialPresetID || !AGENT_AVATARS.isAvatarID(initialPresetID)) { diff --git a/src/pages/settings/Agents/Fields/EditNamePage.tsx b/src/pages/settings/Agents/Fields/EditNamePage.tsx index 6012562e1784..092d13abb51f 100644 --- a/src/pages/settings/Agents/Fields/EditNamePage.tsx +++ b/src/pages/settings/Agents/Fields/EditNamePage.tsx @@ -7,7 +7,7 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateAgentName} from '@libs/actions/Agent'; @@ -29,7 +29,7 @@ function EditNamePage({route}: EditNamePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const accountID = route.params.accountID; - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (list) => list?.[accountID]}); + const [personalDetails] = usePersonalDetail(accountID); const {inputCallbackRef} = useAutoFocusInput(); diff --git a/src/pages/settings/Agents/Fields/EditPromptPage.tsx b/src/pages/settings/Agents/Fields/EditPromptPage.tsx index 1e3d0644a5a9..82349412396f 100644 --- a/src/pages/settings/Agents/Fields/EditPromptPage.tsx +++ b/src/pages/settings/Agents/Fields/EditPromptPage.tsx @@ -20,7 +20,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE} from '@pages/settings/Agents/const'; +import {PROMPT_MAX_AUTO_GROW_HEIGHT, PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE} from '@pages/settings/Agents/const'; import scrollToMultilineInput from '@pages/settings/Agents/scrollToMultilineInput'; import CONST from '@src/CONST'; @@ -40,12 +40,16 @@ function EditPromptPage({route}: EditPromptPageProps) { const styles = useThemeStyles(); const {isKeyboardActive} = useKeyboardState(); const isInLandscapeMode = useIsInLandscapeMode(); + // On native portrait the prompt grows with its content up to a max height instead of filling the screen, so the open + // keyboard can't squeeze it or hide the line being edited, and the form scrolls to fit it above the keyboard. + const shouldAutoGrowPromptInput = Platform.OS !== 'web' && !isInLandscapeMode; + const shouldUseScrollableLayout = shouldAutoGrowPromptInput || isInLandscapeMode; const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive; const accountID = route.params.accountID; const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); const formRef = useRef(null); const promptTopOffsetRef = useRef(0); - const scrollToInput = () => scrollToMultilineInput(formRef, isInLandscapeMode, promptTopOffsetRef.current); + const scrollToInput = () => scrollToMultilineInput(formRef, shouldUseScrollableLayout, promptTopOffsetRef.current); const validate = (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; @@ -84,6 +88,7 @@ function EditPromptPage({route}: EditPromptPageProps) { testID={EditPromptPage.displayName} includeSafeAreaPaddingBottom offlineIndicatorStyle={styles.mtAuto} + shouldEnableMaxHeight={shouldAutoGrowPromptInput} > { promptTopOffsetRef.current = event.nativeEvent.layout.y; }} @@ -123,9 +132,11 @@ function EditPromptPage({route}: EditPromptPageProps) { excludedMarkdownStyles={['mentionReport']} defaultValue={Str.htmlDecode(agentPrompt?.prompt ?? '')} multiline - containerStyles={[styles.h100]} - touchableInputWrapperStyle={[styles.flex1]} - inputStyle={[styles.flex1, styles.textAlignVerticalTop]} + autoGrowHeight={shouldAutoGrowPromptInput} + maxAutoGrowHeight={shouldAutoGrowPromptInput ? PROMPT_MAX_AUTO_GROW_HEIGHT : undefined} + containerStyles={shouldAutoGrowPromptInput ? undefined : [styles.h100]} + touchableInputWrapperStyle={shouldAutoGrowPromptInput ? undefined : [styles.flex1]} + inputStyle={[!shouldAutoGrowPromptInput && styles.flex1, styles.textAlignVerticalTop]} onFocus={scrollToInput} /> diff --git a/src/pages/settings/Agents/const/index.ios.ts b/src/pages/settings/Agents/const/index.ios.ts index 6e7180062c4a..1ccf6e72e016 100644 --- a/src/pages/settings/Agents/const/index.ios.ts +++ b/src/pages/settings/Agents/const/index.ios.ts @@ -1,8 +1,8 @@ import getPromptMaxHeightOnKeyboardOpenLandscapeMode from './getPromptMaxHeightOnKeyboardOpenLandscapeMode'; +import PROMPT_MAX_AUTO_GROW_HEIGHT from './promptMaxAutoGrowHeight'; // The iOS keyboard is smaller than the Android keyboard, so we want to show one more line compared to the Android version. const MAX_PROMPT_LINES_WITH_KEYBOARD = 3; const PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE = getPromptMaxHeightOnKeyboardOpenLandscapeMode(MAX_PROMPT_LINES_WITH_KEYBOARD); -// eslint-disable-next-line import/prefer-default-export -export {PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE}; +export {PROMPT_MAX_AUTO_GROW_HEIGHT, PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE}; diff --git a/src/pages/settings/Agents/const/index.ts b/src/pages/settings/Agents/const/index.ts index ff8f80e29b72..e779c98922a0 100644 --- a/src/pages/settings/Agents/const/index.ts +++ b/src/pages/settings/Agents/const/index.ts @@ -1,7 +1,7 @@ import getPromptMaxHeightOnKeyboardOpenLandscapeMode from './getPromptMaxHeightOnKeyboardOpenLandscapeMode'; +import PROMPT_MAX_AUTO_GROW_HEIGHT from './promptMaxAutoGrowHeight'; const MAX_PROMPT_LINES_WITH_KEYBOARD = 2; const PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE = getPromptMaxHeightOnKeyboardOpenLandscapeMode(MAX_PROMPT_LINES_WITH_KEYBOARD); -// eslint-disable-next-line import/prefer-default-export -export {PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE}; +export {PROMPT_MAX_AUTO_GROW_HEIGHT, PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE}; diff --git a/src/pages/settings/Agents/const/promptMaxAutoGrowHeight.ts b/src/pages/settings/Agents/const/promptMaxAutoGrowHeight.ts new file mode 100644 index 000000000000..a63680116eb1 --- /dev/null +++ b/src/pages/settings/Agents/const/promptMaxAutoGrowHeight.ts @@ -0,0 +1,7 @@ +import getPromptMaxHeightOnKeyboardOpenLandscapeMode from './getPromptMaxHeightOnKeyboardOpenLandscapeMode'; + +// Tall enough to comfortably read and edit the prompt, while still fitting above the keyboard on smaller phones. +const MAX_PROMPT_LINES_WITH_AUTO_GROW = 10; +const PROMPT_MAX_AUTO_GROW_HEIGHT = getPromptMaxHeightOnKeyboardOpenLandscapeMode(MAX_PROMPT_LINES_WITH_AUTO_GROW); + +export default PROMPT_MAX_AUTO_GROW_HEIGHT; diff --git a/src/pages/settings/Copilot/CopilotPage.tsx b/src/pages/settings/Copilot/CopilotPage.tsx index efff967e8c38..2907b2fbcbf5 100644 --- a/src/pages/settings/Copilot/CopilotPage.tsx +++ b/src/pages/settings/Copilot/CopilotPage.tsx @@ -33,7 +33,7 @@ import {clearDelegateErrorsByField, openSecuritySettingsPage, removeDelegate, re import {getLatestError} from '@libs/ErrorUtils'; import getClickedTargetLocation from '@libs/getClickedTargetLocation'; import Navigation from '@libs/Navigation/Navigation'; -import {sortAlphabetically} from '@libs/OptionsListUtils'; +import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; diff --git a/src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsConfirmValidateCodePage.tsx b/src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsConfirmValidateCodePage.tsx index b148c224c90a..526ac5c0abe1 100644 --- a/src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsConfirmValidateCodePage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsConfirmValidateCodePage.tsx @@ -18,6 +18,7 @@ import ROUTES from '@src/ROUTES'; import type {PersonalDetailsForm} from '@src/types/form'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {CONST as COMMON_CONST} from 'expensify-common'; import React, {useEffect, useRef} from 'react'; function PrivatePersonalDetailsConfirmValidateCodePage() { @@ -73,7 +74,8 @@ function PrivatePersonalDetailsConfirmValidateCodePage() { requestValidateCodeAction()} + sendValidateCode={() => requestValidateCodeAction({reasonCode: COMMON_CONST.VALIDATE_CODE_REASONS.UPDATE_PERSONAL_DETAILS})} + validateCodeReasonCode={COMMON_CONST.VALIDATE_CODE_REASONS.UPDATE_PERSONAL_DETAILS} validateCodeActionErrorField="personalDetails" handleSubmitForm={handleSubmitForm} validateError={submitError} diff --git a/src/pages/settings/Report/DynamicNotificationPreferencePage.tsx b/src/pages/settings/Report/DynamicNotificationPreferencePage.tsx index 1ad16a6bc8c4..b4e7b957ebd1 100644 --- a/src/pages/settings/Report/DynamicNotificationPreferencePage.tsx +++ b/src/pages/settings/Report/DynamicNotificationPreferencePage.tsx @@ -10,7 +10,7 @@ import useLocalize from '@hooks/useLocalize'; import useReportIsArchived from '@hooks/useReportIsArchived'; import Navigation from '@libs/Navigation/Navigation'; -import {getReportNotificationPreference, isArchivedNonExpenseReport, isHiddenForCurrentUser, isMoneyRequestReport, isSelfDM} from '@libs/ReportUtils'; +import {getReportNotificationPreferenceForSettings, isArchivedNonExpenseReport, isHiddenForCurrentUser, isSelfDM} from '@libs/ReportUtils'; import withReportOrNotFound from '@pages/inbox/report/withReportOrNotFound'; import type {WithReportOrNotFoundProps} from '@pages/inbox/report/withReportOrNotFound'; @@ -30,13 +30,13 @@ function DynamicNotificationPreferencePage({report}: DynamicNotificationPreferen const {translate} = useLocalize(); const isReportArchived = useReportIsArchived(report?.reportID); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const isMoneyRequest = isMoneyRequestReport(report); - const currentNotificationPreference = getReportNotificationPreference(report); + const currentUserParticipant = currentUserAccountID ? report?.participants?.[currentUserAccountID] : undefined; + const currentUserNotificationPreference = currentUserParticipant?.notificationPreference; + const currentNotificationPreference = getReportNotificationPreferenceForSettings(report, currentUserAccountID); const [draftNotificationPreference, setDraftNotificationPreference] = useState | undefined>(undefined); const selectedNotificationPreference = draftNotificationPreference ?? currentNotificationPreference; - const shouldDisableNotificationPreferences = - isArchivedNonExpenseReport(report, isReportArchived) || isSelfDM(report) || (!isMoneyRequest && isHiddenForCurrentUser(currentNotificationPreference)); + const shouldDisableNotificationPreferences = isArchivedNonExpenseReport(report, isReportArchived) || isSelfDM(report) || !currentUserParticipant; const notificationPreferenceOptions = Object.values(CONST.REPORT.NOTIFICATION_PREFERENCE) .filter((pref) => !isHiddenForCurrentUser(pref)) .map((preference) => ({ @@ -52,7 +52,7 @@ function DynamicNotificationPreferencePage({report}: DynamicNotificationPreferen }, [backPath]); const saveNotificationPreference = () => { - updateNotificationPreference(report.reportID, currentNotificationPreference, selectedNotificationPreference, currentUserAccountID, undefined, undefined); + updateNotificationPreference(report.reportID, currentUserNotificationPreference, selectedNotificationPreference, currentUserAccountID, undefined, undefined); goBack(); }; @@ -60,7 +60,7 @@ function DynamicNotificationPreferencePage({report}: DynamicNotificationPreferen showButton: true, text: translate('common.save'), onConfirm: saveNotificationPreference, - isDisabled: selectedNotificationPreference === currentNotificationPreference, + isDisabled: selectedNotificationPreference === currentUserNotificationPreference, }; return ( diff --git a/src/pages/settings/Report/DynamicReportSettingsPage.tsx b/src/pages/settings/Report/DynamicReportSettingsPage.tsx index e250f12273c9..927a7bbfe980 100644 --- a/src/pages/settings/Report/DynamicReportSettingsPage.tsx +++ b/src/pages/settings/Report/DynamicReportSettingsPage.tsx @@ -5,6 +5,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -16,10 +17,9 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import { canEditRoomVisibility, canEditWriteCapability, - getReportNotificationPreference, + getReportNotificationPreferenceForSettings, isAdminRoom, isArchivedNonExpenseReport as isArchivedNonExpenseReportUtils, - isHiddenForCurrentUser, isMoneyRequestReport as isMoneyRequestReportUtils, isSelfDM, } from '@libs/ReportUtils'; @@ -45,22 +45,21 @@ function DynamicReportSettingsPage({report, policy}: DynamicReportSettingsPagePr const {translate} = useLocalize(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_SETTINGS.path); const isReportArchived = useReportIsArchived(reportID); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const currentUserParticipant = currentUserAccountID ? report?.participants?.[currentUserAccountID] : undefined; const isArchivedNonExpenseReport = isArchivedNonExpenseReportUtils(report, isReportArchived); // The workspace the report is on, null if the user isn't a member of the workspace const linkedWorkspace = useMemo(() => (report?.policyID && policy?.id === report?.policyID ? policy : undefined), [policy, report?.policyID]); const isMoneyRequestReport = isMoneyRequestReportUtils(report); const shouldDisableSettings = isArchivedNonExpenseReport || isEmptyObject(report) || isSelfDM(report); - const notificationPreferenceValue = getReportNotificationPreference(report); - const notificationPreference = - notificationPreferenceValue && !isHiddenForCurrentUser(notificationPreferenceValue) - ? translate(`notificationPreferencesPage.notificationPreferences.${notificationPreferenceValue}`) - : ''; + const notificationPreferenceValue = getReportNotificationPreferenceForSettings(report, currentUserAccountID); + const notificationPreference = notificationPreferenceValue ? translate(`notificationPreferencesPage.notificationPreferences.${notificationPreferenceValue}`) : ''; const writeCapability = isAdminRoom(report) ? CONST.REPORT.WRITE_CAPABILITIES.ADMINS : (report?.writeCapability ?? CONST.REPORT.WRITE_CAPABILITIES.ALL); const writeCapabilityText = translate(`writeCapabilityPage.writeCapability.${writeCapability}`); const shouldAllowWriteCapabilityEditing = useMemo(() => canEditWriteCapability(report, linkedWorkspace, isReportArchived), [report, linkedWorkspace, isReportArchived]); const shouldAllowChangeVisibility = useMemo(() => canEditRoomVisibility(linkedWorkspace, isArchivedNonExpenseReport), [linkedWorkspace, isArchivedNonExpenseReport]); - const shouldShowNotificationPref = !isMoneyRequestReport && !isHiddenForCurrentUser(notificationPreferenceValue); + const shouldShowNotificationPref = !isMoneyRequestReport && !!currentUserParticipant; const shouldShowWriteCapability = !isMoneyRequestReport; diff --git a/src/pages/settings/Rules/RulePageBase.tsx b/src/pages/settings/Rules/RulePageBase.tsx index 4583e8c64115..6c06945b0a10 100644 --- a/src/pages/settings/Rules/RulePageBase.tsx +++ b/src/pages/settings/Rules/RulePageBase.tsx @@ -1,7 +1,9 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import MenuItemFieldHTML from '@components/MenuItem/presets/MenuItemFieldHTML'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -24,6 +26,8 @@ import {getTagArrayFromName} from '@libs/TransactionUtils'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -44,20 +48,6 @@ type RulePageBaseProps = { hash?: string; }; -type SectionItemType = { - key: string; - description: string; - required?: boolean; - title?: string; - onPress: () => void; - shouldRenderAsHTML?: boolean; -}; - -type SectionType = { - titleTranslationKey: TranslationPaths; - items: Array; -}; - const navigateTo = (field: ExpenseRuleFormFieldID, hash?: string, index?: number) => { if (hash) { Navigation.navigate(ROUTES.SETTINGS_RULES_EDIT.getRoute(hash, field, index)); @@ -156,83 +146,8 @@ function RulePageBase({titleKey, testID, hash}: RulePageBaseProps) { }); }; - const sections: SectionType[] = [ - { - titleTranslationKey: 'expenseRulesPage.addRule.expenseContains', - items: [ - { - key: 'merchantToMatch', - description: translate('common.merchant'), - required: true, - title: form?.merchantToMatch, - onPress: () => navigateTo(EXPENSE_RULE_INPUT_IDS.MERCHANT, hash), - }, - ], - }, - { - titleTranslationKey: 'expenseRulesPage.addRule.applyUpdates', - items: [ - { - key: 'merchant', - description: translate('common.merchant'), - title: form?.merchant, - onPress: () => navigateTo(EXPENSE_RULE_INPUT_IDS.RENAME_MERCHANT, hash), - }, - form?.category || hasPolicyCategories - ? { - key: 'category', - description: translate('common.category'), - title: form?.category ? getDecodedCategoryName(form.category) : undefined, - onPress: () => navigateTo(EXPENSE_RULE_INPUT_IDS.CATEGORY, hash), - } - : undefined, - ...policyTags - .filter(({orderWeight, tags}) => !!formTags.at(orderWeight) || getEnabledTags(tags, form?.tag ?? '', orderWeight).length > 0) - .map(({name, orderWeight}) => { - const formTag = formTags.at(orderWeight); - return { - key: `tag-${name}-${orderWeight}`, - description: name, - title: formTag ? getCleanedTagName(formTag) : undefined, - onPress: () => navigateTo(EXPENSE_RULE_INPUT_IDS.TAG, hash, orderWeight), - }; - }), - hasTaxRates - ? { - key: 'tax', - description: translate('common.tax'), - title: selectedTaxRate ? `${selectedTaxRate.name} (${selectedTaxRate.value})` : undefined, - onPress: () => navigateTo(EXPENSE_RULE_INPUT_IDS.TAX, hash), - } - : undefined, - { - key: 'description', - description: translate('common.description'), - title: form?.comment ? Parser.replace(form.comment) : undefined, - onPress: () => navigateTo(EXPENSE_RULE_INPUT_IDS.DESCRIPTION, hash), - shouldRenderAsHTML: true, - }, - { - key: 'reimbursable', - description: translate('common.reimbursable'), - title: form?.reimbursable ? translate(form.reimbursable === 'true' ? 'common.yes' : 'common.no') : translate('common.dontChange'), - onPress: () => navigateTo(EXPENSE_RULE_INPUT_IDS.REIMBURSABLE, hash), - }, - { - key: 'billable', - description: translate('common.billable'), - title: form?.billable ? translate(form.billable === 'true' ? 'common.yes' : 'common.no') : translate('common.dontChange'), - onPress: () => navigateTo(EXPENSE_RULE_INPUT_IDS.BILLABLE, hash), - }, - { - key: 'addToReport', - description: translate('expenseRulesPage.addRule.addToReport'), - title: form?.report, - onPress: () => navigateTo(EXPENSE_RULE_INPUT_IDS.REPORT, hash), - }, - ], - }, - ]; + const hasMerchantToMatchError = shouldShowError && !form?.merchantToMatch; + const tagsToShow = policyTags.filter(({orderWeight, tags}) => !!formTags.at(orderWeight) || getEnabledTags(tags, form?.tag ?? '', orderWeight).length > 0); return ( - {sections.map((section) => ( - - {translate(section.titleTranslationKey)} - {section.items.map((item) => { - if (!item) { - return null; - } - return ( - - ); - })} - - ))} + {translate('expenseRulesPage.addRule.expenseContains')} + navigateTo(EXPENSE_RULE_INPUT_IDS.MERCHANT, hash))}> + + {!form?.merchantToMatch && !hasMerchantToMatchError && {translate('common.required')}} + + + {hasMerchantToMatchError && ( + + )} + + {translate('expenseRulesPage.addRule.applyUpdates')} + navigateTo(EXPENSE_RULE_INPUT_IDS.RENAME_MERCHANT, hash)} + /> + {(!!form?.category || !!hasPolicyCategories) && ( + navigateTo(EXPENSE_RULE_INPUT_IDS.CATEGORY, hash)} + /> + )} + {tagsToShow.map(({name, orderWeight}) => { + const formTag = formTags.at(orderWeight); + return ( + navigateTo(EXPENSE_RULE_INPUT_IDS.TAG, hash, orderWeight)} + /> + ); + })} + {hasTaxRates && ( + navigateTo(EXPENSE_RULE_INPUT_IDS.TAX, hash)} + /> + )} + navigateTo(EXPENSE_RULE_INPUT_IDS.DESCRIPTION, hash)} + /> + navigateTo(EXPENSE_RULE_INPUT_IDS.REIMBURSABLE, hash)} + /> + navigateTo(EXPENSE_RULE_INPUT_IDS.BILLABLE, hash)} + /> + navigateTo(EXPENSE_RULE_INPUT_IDS.REPORT, hash)} + /> { return null; } - if (err.includes('Invalid validateCode')) { + if (err.includes(CONST.MERGE_ACCOUNT_INVALID_CODE_ERROR)) { return 'mergeAccountsPage.accountValidate.errors.incorrectSecurityCode'; } diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyRenewalBillingBanner/index.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyRenewalBillingBanner/index.tsx new file mode 100644 index 000000000000..9b7ae3077b1b --- /dev/null +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyRenewalBillingBanner/index.tsx @@ -0,0 +1,56 @@ +/** The billing owner's early renewal offer, shown above their payment details when CardSection finds them eligible. */ +import Button from '@components/Button'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import useEarlyRenewalConfirmation from '@hooks/useEarlyRenewalConfirmation'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearEarlyRenewalOfferErrors} from '@libs/actions/EarlyRenewalOffer'; + +import BillingBanner from '@pages/settings/Subscription/CardSection/BillingBanner/BillingBanner'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; + +function EarlyRenewalBillingBanner() { + const [eligibility] = useOnyx(ONYXKEYS.EARLY_RENEWAL_OFFER_ELIGIBILITY); + const showEarlyRenewalConfirmation = useEarlyRenewalConfirmation(); + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['SubscriptionAnnual']); + + return ( + + + {translate('earlyRenewal.renew')} + + } + /> + + ); +} + +export default EarlyRenewalBillingBanner; diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/PreTrialBillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/PreTrialBillingBanner.tsx index 936db7f50829..33f42d9ef17d 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/PreTrialBillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/PreTrialBillingBanner.tsx @@ -4,12 +4,14 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; import {navigateToConciergeChat} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import {getChatUsedForOnboarding} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -25,6 +27,7 @@ function PreTrialBillingBanner() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const isSelfTourViewed = onboarding?.selfTourViewed; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -32,7 +35,7 @@ function PreTrialBillingBanner() { const reportUsedForOnboarding = getChatUsedForOnboarding(onboarding, conciergeReportID); if (!reportUsedForOnboarding) { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: false}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); return; } diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index a5617c757e49..7994bba412ac 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -6,6 +6,7 @@ import RenderHTML from '@components/RenderHTML'; import Section from '@components/Section'; import useConfirmModal from '@hooks/useConfirmModal'; +import useEarlyRenewalPeriod from '@hooks/useEarlyRenewalPeriod'; import useHasTeam2025Pricing from '@hooks/useHasTeam2025Pricing'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -43,6 +44,7 @@ import {View} from 'react-native'; import type {BillingStatusResult} from './utils'; import EarlyDiscountBanner from './BillingBanner/EarlyDiscountBanner'; +import EarlyRenewalBillingBanner from './BillingBanner/EarlyRenewalBillingBanner'; import PreTrialBillingBanner from './BillingBanner/PreTrialBillingBanner'; import SubscriptionBillingBanner from './BillingBanner/SubscriptionBillingBanner'; import SubscriptionExpiringSoonBanner from './BillingBanner/SubscriptionExpiringSoonBanner'; @@ -62,6 +64,8 @@ function CardSection() { const expensifyIcons = useMemoizedLazyExpensifyIcons(['History', 'Bill', 'Close']); const illustrations = useMemoizedLazyIllustrations(['CreditCardEyes']); const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [earlyRenewalEligibility, earlyRenewalEligibilityMetadata] = useOnyx(ONYXKEYS.EARLY_RENEWAL_OFFER_ELIGIBILITY); + const {isNonIncentivizedPeriod} = useEarlyRenewalPeriod(); const privateSubscription = usePrivateSubscription(); const [privateStripeCustomerID] = useOnyx(ONYXKEYS.NVP_PRIVATE_STRIPE_CUSTOMER_ID); const [authenticationLink] = useOnyx(ONYXKEYS.VERIFY_3DS_SUBSCRIPTION); @@ -226,9 +230,12 @@ function CardSection() { }; let BillingBanner: React.ReactNode | undefined; - // Checked before the trial banners because the pre-trial check also passes when the free trial NVPs are absent, - // which an annual subscriber can have - if (shouldShowSubscriptionExpiringSoonUI(privateSubscription)) { + // Renewing early also resolves an expiring subscription, so the offer wins over every other non-error banner + if (earlyRenewalEligibilityMetadata.status === 'loaded' && earlyRenewalEligibility?.canClaim && isNonIncentivizedPeriod) { + BillingBanner = ; + } else if (shouldShowSubscriptionExpiringSoonUI(privateSubscription)) { + // Checked before the trial banners because the pre-trial check also passes when the free trial NVPs are absent, + // which an annual subscriber can have BillingBanner = ; } else if (shouldShowDiscountBanner(session?.accountID, hasTeam2025Pricing, subscriptionPlan, firstDayFreeTrial, lastDayFreeTrial, userBillingFundID, allPolicies)) { BillingBanner = ; diff --git a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentHistoryTable.tsx b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentHistoryTable.tsx new file mode 100644 index 000000000000..8ea268e76245 --- /dev/null +++ b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentHistoryTable.tsx @@ -0,0 +1,156 @@ +import Badge from '@components/Badge'; +import MenuItem from '@components/MenuItem'; +import Section from '@components/Section'; +import SkeletonTextLine from '@components/Skeletons/SkeletonTextLine'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; +import DateUtils from '@libs/DateUtils'; +import type {PaymentHistoryRow} from '@libs/PaymentHistoryUtils'; +import {getBadgeAppearance, getPaymentHistoryRows, getStateTranslationKey} from '@libs/PaymentHistoryUtils'; + +import {lineHeightScale} from '@styles/typography'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import {View} from 'react-native'; + +const SKELETON_ROWS = [0, 1, 2, 3]; +const SKELETON_TITLE_WIDTH = 180; +const SKELETON_SUBTITLE_WIDTH = 120; + +// The payment details page is a later change, so pressing a row does nothing yet. +function ignorePaymentRowPress() {} + +function PaymentHistoryTable() { + const {translate, dateFnsLocale} = useLocalize(); + const styles = useThemeStyles(); + const [purchaseList] = useOnyx(ONYXKEYS.PURCHASE_LIST); + const isLoading = purchaseList === undefined; + const rows = getPaymentHistoryRows(purchaseList); + + return ( +
( + + + {translate('subscription.paymentHistory.payments')} + + + )} + > + {isLoading && } + {!isLoading && rows.length === 0 && ( + {translate('subscription.paymentHistory.empty')} + )} + {!isLoading && + rows.map((row, index) => ( + + ))} +
+ ); +} + +type PaymentHistoryTableRowProps = { + row: PaymentHistoryRow; + dateLabel: string; + shouldShowDivider: boolean; +}; + +function PaymentHistoryTableRow({row, dateLabel, shouldShowDivider}: PaymentHistoryTableRowProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const stateLabel = translate(getStateTranslationKey(row.state)); + const amountLabel = convertAmountToDisplayString(row.amount, row.currency); + const activeUserLabel = row.activeUserCount ? translate('subscription.paymentHistory.activeUsers', {count: row.activeUserCount}) : undefined; + const taxLabel = row.showsTax ? translate('subscription.paymentHistory.inclTax') : undefined; + const {success, error} = getBadgeAppearance(row.state); + + return ( + + + + + {dateLabel} + + + + {!!activeUserLabel && {activeUserLabel}} + + + + + + {amountLabel} + + {!!taxLabel && {taxLabel}} + + + + + + ); +} + +function PaymentHistorySkeleton() { + const styles = useThemeStyles(); + + return ( + + {SKELETON_ROWS.map((row) => ( + + + + + ))} + + ); +} + +export default PaymentHistoryTable; diff --git a/src/pages/settings/Subscription/PaymentHistoryPage/index.tsx b/src/pages/settings/Subscription/PaymentHistoryPage/index.tsx index e9a8512c5f15..9f9a4fcbd4c1 100644 --- a/src/pages/settings/Subscription/PaymentHistoryPage/index.tsx +++ b/src/pages/settings/Subscription/PaymentHistoryPage/index.tsx @@ -1,5 +1,5 @@ import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -8,8 +8,6 @@ import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; - import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import {openPaymentHistoryPage} from '@userActions/Subscription'; @@ -19,6 +17,8 @@ import CONST from '@src/CONST'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback} from 'react'; +import PaymentHistoryTable from './PaymentHistoryTable'; + function PaymentHistoryPage() { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -45,12 +45,10 @@ function PaymentHistoryPage() { shouldEnableMaxHeight > - + - {translate('subscription.paymentHistory.subtitle')} + {translate('subscription.paymentHistory.subtitle')} +
diff --git a/src/pages/settings/Subscription/SubscriptionSettings/index.tsx b/src/pages/settings/Subscription/SubscriptionSettings/index.tsx index 29391cf49ff0..208fb2a9d373 100644 --- a/src/pages/settings/Subscription/SubscriptionSettings/index.tsx +++ b/src/pages/settings/Subscription/SubscriptionSettings/index.tsx @@ -21,6 +21,7 @@ import useHasTeam2025Pricing from '@hooks/useHasTeam2025Pricing'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; import usePreferredCurrency from '@hooks/usePreferredCurrency'; import usePrivateSubscription from '@hooks/usePrivateSubscription'; @@ -88,6 +89,7 @@ function SubscriptionSettings() { const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard), [fundList]); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const {isSecretPromoCode, promoDiscountValue} = getPrivatePromoDiscountInfo(privatePromoDiscount, isAnnual); @@ -355,7 +357,14 @@ function SubscriptionSettings() { { requestTaxExempt(); - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: false}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + conciergePersonalDetails, + shouldDismissModal: false, + }); }} > diff --git a/src/pages/settings/Troubleshoot/ServerPage.tsx b/src/pages/settings/Troubleshoot/ServerPage.tsx new file mode 100644 index 000000000000..5a18ea8f710d --- /dev/null +++ b/src/pages/settings/Troubleshoot/ServerPage.tsx @@ -0,0 +1,18 @@ +import ScreenWrapper from '@components/ScreenWrapper'; + +import React from 'react'; + +import ServerSelector from './ServerSelector'; + +function ServerPage() { + return ( + + + + ); +} + +export default ServerPage; diff --git a/src/pages/settings/Troubleshoot/ServerSelector.tsx b/src/pages/settings/Troubleshoot/ServerSelector.tsx new file mode 100644 index 000000000000..fd7d59e92fa7 --- /dev/null +++ b/src/pages/settings/Troubleshoot/ServerSelector.tsx @@ -0,0 +1,91 @@ +/** + * Lets a tester point the app at a different API server, and disables the choice on builds where the environment pins one. + */ +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +import Text from '@components/Text'; + +import useActiveServer from '@hooks/useActiveServer'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {Server} from '@libs/ApiUtils'; +import {isQAAuthConfigured} from '@libs/CloudflareAccess/Config'; +import Navigation from '@libs/Navigation/Navigation'; + +import {setActiveServer} from '@userActions/User'; + +import CONST from '@src/CONST'; +import type {Route} from '@src/ROUTES'; + +import React, {useState} from 'react'; + +type ServerListItem = ListItem & {keyForList: Server}; + +type ServerSelectorProps = { + shouldAddBottomSafeAreaPadding?: boolean; + backToRoute?: Route; +}; + +function ServerSelector({shouldAddBottomSafeAreaPadding = false, backToRoute}: ServerSelectorProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {activeServer, isPinnedByEnvironment, isStagingIgnored, isQASelectable} = useActiveServer(); + + // The resolved server arrives a tick after mount, so it cannot seed this state + const [pickedServer, setPickedServer] = useState(); + const selectedServer = pickedServer ?? activeServer; + + const canPickQA = isQASelectable && isQAAuthConfigured(); + const offeredServers = [CONST.SERVER.PRODUCTION, ...(isStagingIgnored ? [] : [CONST.SERVER.STAGING]), ...(canPickQA ? [CONST.SERVER.QA] : [])]; + + // A pinned build can be on a server the list would not otherwise offer + const listedServers = offeredServers.includes(activeServer) ? offeredServers : [...offeredServers, activeServer]; + + const servers: ServerListItem[] = listedServers.map((server) => ({ + text: translate(`initialSettingsPage.troubleshoot.servers.${server}.label`), + alternateText: translate(`initialSettingsPage.troubleshoot.servers.${server}.description`), + keyForList: server, + isSelected: selectedServer === server, + })); + + const goBack = () => Navigation.goBack(backToRoute, {compareParams: false}); + + const saveAndGoBack = () => { + setActiveServer(selectedServer); + goBack(); + }; + + const confirmButtonOptions = { + showButton: !isPinnedByEnvironment, + text: translate('common.save'), + onConfirm: saveAndGoBack, + isDisabled: selectedServer === activeServer, + }; + + return ( + <> + + setPickedServer(server.keyForList)} + shouldSingleExecuteRowSelect + confirmButtonOptions={confirmButtonOptions} + isDisabled={isPinnedByEnvironment} + customListHeaderContent={ + isPinnedByEnvironment ? {translate('initialSettingsPage.troubleshoot.serverPinnedDescription')} : undefined + } + initiallyFocusedItemKey={activeServer} + addBottomSafeAreaPadding={shouldAddBottomSafeAreaPadding} + /> + + ); +} + +export default ServerSelector; diff --git a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx index f2c64e8fa22e..7d613e0d7d04 100644 --- a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx +++ b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx @@ -42,7 +42,7 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; import type IconAsset from '@src/types/utils/IconAsset'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -245,7 +245,7 @@ function TroubleshootPage() { shouldUseSingleExecution /> - + {isDevelopment && ( diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index 673e53da450a..eea07fcf680f 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -476,34 +476,28 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { {shouldShowReportLostCardButton && ( <> - {shouldShowPIN && ( - { - executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.REVEAL_PIN, { - cardID: String(currentPhysicalCard?.cardID), - }); - }} - > - - {translate('cardPage.revealPin')} - - ) : undefined - } - /> + + {canRevealPIN && ( + { + executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.REVEAL_PIN, { + cardID: String(currentPhysicalCard?.cardID), + }); + }} + > + + {translate('cardPage.revealPin')} + + )} + )} )} @@ -644,12 +638,10 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { <> {shouldShowReportVirtualCardFraudRows && virtualCards.map((card) => ( - { if (isAccountLocked) { showLockedAccountModal(); @@ -665,12 +657,10 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { ))} {shouldShowReportTravelCardFraudRows && travelCards.map((card) => ( - Navigation.navigate(ROUTES.SETTINGS_REPORT_FRAUD.getRoute(String(card.cardID)))} /> ))} diff --git a/src/pages/settings/Wallet/ImportTransactionsPage.tsx b/src/pages/settings/Wallet/ImportTransactionsPage.tsx index 71475aadc7fa..d6b6eb201265 100644 --- a/src/pages/settings/Wallet/ImportTransactionsPage.tsx +++ b/src/pages/settings/Wallet/ImportTransactionsPage.tsx @@ -1,7 +1,7 @@ import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -15,6 +15,8 @@ import Navigation from '@libs/Navigation/Navigation'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -52,6 +54,8 @@ function ImportTransactionsPage() { Navigation.navigate(ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_SPREADSHEET.getRoute()); }, [cardDisplayName, currency, isReimbursable, flipAmountSign]); + const shouldShowCardDisplayNameError = shouldShowError && !cardDisplayName; + return ( {translate('workspace.companyCards.importTransactions.description')} - + + + {shouldShowCardDisplayNameError && } + {!cardDisplayName && !shouldShowCardDisplayNameError && {translate('common.required')}} + + + {shouldShowCardDisplayNameError && ( + + )} + ) => { - updateAssignedCardName(cardID, values[INPUT_IDS.NAME], defaultValue); + updateAssignedCardName(cardID, StringUtils.sanitizeName(values[INPUT_IDS.NAME]), defaultValue); Navigation.goBack(ROUTES.SETTINGS_WALLET_PERSONAL_CARD_DETAILS.getRoute(cardID)); }; const validate = (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [INPUT_IDS.NAME], translate); - if (values.name) { - const {isValid, byteLength} = isValidInputLength(values.name, CONST.STANDARD_LENGTH_LIMIT); - if (!isValid) { - addErrorMessage(errors, INPUT_IDS.NAME, translate('common.error.characterLimitExceedCounter', byteLength, CONST.STANDARD_LENGTH_LIMIT)); - } + const errors: FormInputErrors = {}; + const error = getCardNameError(values.name); + + if (error) { + errors[INPUT_IDS.NAME] = getCardNameErrorMessage(translate, error, values.name); } + return errors; }; diff --git a/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx b/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx index 8ab164545183..1cf62b6c5bf8 100644 --- a/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx +++ b/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx @@ -5,6 +5,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; import {navigateToConciergeChat} from '@libs/actions/Report'; @@ -31,6 +32,7 @@ function AddPersonalNewCardPage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const {currentStep} = addNewPersonalCardFeed ?? {}; const [isModalVisible, setIsModalVisible] = useState(false); const {showConfirmModal} = useConfirmModal(); @@ -75,7 +77,7 @@ function AddPersonalNewCardPage() { if (result.action !== ModalActions.CONFIRM) { return; } - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); }) .finally(() => { setIsModalVisible(false); diff --git a/src/pages/settings/Wallet/PersonalCards/PersonalCardsErrorConfirmation.tsx b/src/pages/settings/Wallet/PersonalCards/PersonalCardsErrorConfirmation.tsx index b70ab0bcb391..531a6321918f 100644 --- a/src/pages/settings/Wallet/PersonalCards/PersonalCardsErrorConfirmation.tsx +++ b/src/pages/settings/Wallet/PersonalCards/PersonalCardsErrorConfirmation.tsx @@ -14,7 +14,12 @@ import CONST from '@src/CONST'; import React from 'react'; -function PersonalCardsErrorConfirmation() { +type PersonalCardsErrorConfirmationProps = { + /** Backend-supplied bank connection error message to display */ + errorMessage?: string; +}; + +function PersonalCardsErrorConfirmation({errorMessage}: PersonalCardsErrorConfirmationProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(['QuestionMark']); @@ -25,6 +30,7 @@ function PersonalCardsErrorConfirmation() { }; const openPlaidLink = () => { + clearAddNewPersonalCardErrors(); setAddNewPersonalCardStepAndData({ step: CONST.PERSONAL_CARDS.STEP.PLAID_CONNECTION, data: { @@ -41,6 +47,7 @@ function PersonalCardsErrorConfirmation() { heading={translate('personalCard.bankConnectionError')} description={ + {!!errorMessage && `${errorMessage} `} {translate('personalCard.bankConnectionDescription')}{' '} ; @@ -111,7 +113,7 @@ function BankConnection() { style={styles.flex1} /> )} - {isNewCardError && } + {isNewCardError && } ); diff --git a/src/pages/settings/Wallet/PersonalCards/steps/BankConnection/index.tsx b/src/pages/settings/Wallet/PersonalCards/steps/BankConnection/index.tsx index 737919bc4b23..fc2c4eb3b0f3 100644 --- a/src/pages/settings/Wallet/PersonalCards/steps/BankConnection/index.tsx +++ b/src/pages/settings/Wallet/PersonalCards/steps/BankConnection/index.tsx @@ -13,6 +13,8 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getLatestErrorMessage} from '@libs/ErrorUtils'; + import PersonalCardsErrorConfirmation from '@pages/settings/Wallet/PersonalCards/PersonalCardsErrorConfirmation'; import useGetNewPersonalCard from '@pages/settings/Wallet/PersonalCards/useGetNewPersonalCard'; @@ -31,18 +33,19 @@ let customWindow: Window | null = null; type BankConnectionContentProps = { hasImportError: boolean; + errorMessage?: string; isPlaid?: boolean; onOpenBankConnectionFlow: () => void; bankName?: string | null; plaidConnectedFeedName?: string; }; -function BankConnectionContent({hasImportError, isPlaid, onOpenBankConnectionFlow, bankName, plaidConnectedFeedName}: BankConnectionContentProps) { +function BankConnectionContent({hasImportError, errorMessage, isPlaid, onOpenBankConnectionFlow, bankName, plaidConnectedFeedName}: BankConnectionContentProps) { const illustrations = useMemoizedLazyIllustrations(['PendingBank']); const styles = useThemeStyles(); const {translate} = useLocalize(); if (hasImportError) { - return ; + return ; } if (!isPlaid) { return ( @@ -82,6 +85,7 @@ function BankConnection() { const onImportPlaidAccounts = useImportPersonalPlaidAccounts(); const newCard = useGetNewPersonalCard(); const hasImportError = !isEmptyObject(addNewCard?.errors); + const errorMessage = getLatestErrorMessage(addNewCard) || undefined; const onOpenBankConnectionFlow = () => { if (!url) { @@ -131,6 +135,7 @@ function BankConnection() { bankAccount.accountData?.state === CONST.BANK_ACCOUNT.STATE.LOCKED) : false; const {shouldShowAddHomeAddress} = useTimeSensitiveHomeAddress(); @@ -132,106 +123,12 @@ function useInitialSettingsPageMenuData(currentUserPersonalDetails: CurrentUserP walletBrickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; } - const {showConfirmModal} = useConfirmModal(); - const confirmModalTitle = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.title') : translate('common.areYouSure'); - const confirmModalPrompt = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.prompt') : translate('initialSettingsPage.signOutConfirmationText'); - const confirmModalConfirmText = isTrackingGPS ? translate('gps.signOutWarningTripInProgress.confirm') : translate('initialSettingsPage.signOut'); - - const showSignOutModal = () => { - return showConfirmModal({ - title: confirmModalTitle, - prompt: confirmModalPrompt, - confirmText: confirmModalConfirmText, - cancelText: translate('common.cancel'), - shouldShowCancelButton: true, - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }); - }; - - const showSaveReceiptsModal = (pendingReceiptCount: number) => { - return showConfirmModal({ - title: translate('initialSettingsPage.saveReceiptsConfirmation.title'), - prompt: translate('initialSettingsPage.saveReceiptsConfirmation.prompt', { - count: pendingReceiptCount, - }), - confirmText: translate('initialSettingsPage.saveReceiptsConfirmation.confirm'), - cancelText: translate('common.cancel'), - shouldShowCancelButton: true, - }); - }; - - // Combined modal for the offline case: warns about losing offline changes and offers to save the pending receipts, so the user is not shown two back-to-back prompts. - const showSaveReceiptsAndSignOutModal = (pendingReceiptCount: number) => { - return showConfirmModal({ - title: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.title'), - prompt: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.prompt', { - count: pendingReceiptCount, - }), - confirmText: translate('initialSettingsPage.saveReceiptsAndSignOutConfirmation.confirm'), - cancelText: translate('common.cancel'), - shouldShowCancelButton: true, - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }); - }; - - // Save must complete before the forced-signout branch dispatches `Onyx.clear`, which wipes the persisted queue that holds these local file paths. - const saveReceipts = async (saveableReceipts: ReturnType) => { - try { - const {savedCount, failedCount, permissionDenied} = await saveReceiptsToGallery(saveableReceipts); - Log.info('[Receipt] Saved pending receipts to gallery before sign-out', false, {savedCount, failedCount, permissionDenied}); - // When the OS denied gallery access the receipts could not be saved. Point the user to Settings with the same alert the manual download flow uses, then let sign-out proceed. - if (permissionDenied) { - showPermissionErrorAlert(translate); - } - } catch (error) { - Log.alert('[Receipt] Unexpected rejection from saveReceiptsToGallery; sign-out continued', {error}); - } - }; - const signOut = async (shouldForceSignout = false) => { - // Forced sign-out (expired session, SAML re-auth) must be non-interactive: it must not touch the gallery flow, which can trigger OS permission prompts and delay the redirect. if (shouldForceSignout) { - return signOutAndRedirectToSignIn(); - } - - // `getSaveablePendingReceiptRequests` is platform-split (web returns `[]`) and image-filtered so we do not promise a save the native gallery API can not deliver. - const saveableReceipts = getSaveablePendingReceiptRequests(); - const shouldWarnBeforeSignOut = network.isOffline || isTrackingGPS; - // Offline + receipts is the common case; merge the offline warning and the save-receipts prompt into a single modal. GPS keeps its own warning, so it falls through to the two-step path below. - const isOfflineReceiptsCase = network.isOffline && !isTrackingGPS && saveableReceipts.length > 0; - - if (!shouldWarnBeforeSignOut && saveableReceipts.length === 0) { - return signOutAndRedirectToSignIn(); + return signOutImmediately(); } - if (isOfflineReceiptsCase) { - const result = await showSaveReceiptsAndSignOutModal(saveableReceipts.length); - if (result.action !== ModalActions.CONFIRM) { - return; - } - await saveReceipts(saveableReceipts); - } else { - if (shouldWarnBeforeSignOut) { - const result = await showSignOutModal(); - if (result.action !== ModalActions.CONFIRM) { - return; - } - } - - if (saveableReceipts.length > 0) { - const result = await showSaveReceiptsModal(saveableReceipts.length); - if (result.action !== ModalActions.CONFIRM) { - return; - } - await saveReceipts(saveableReceipts); - } - } - - if (isTrackingGPS) { - stopGpsTripNotification(); - stopLocationUpdatesAsync(BACKGROUND_LOCATION_TRACKING_TASK_NAME).catch((error) => console.error('[GPS distance request] Failed to stop location tracking', error)); - } - signOut(true); + return signOutInteractively(); }; const surveyThresholdInDays = 30; diff --git a/src/pages/signin/SAMLSignInPage/index.native.tsx b/src/pages/signin/SAMLSignInPage/index.native.tsx index a12d06e536b8..ca50ec0f5513 100644 --- a/src/pages/signin/SAMLSignInPage/index.native.tsx +++ b/src/pages/signin/SAMLSignInPage/index.native.tsx @@ -106,7 +106,7 @@ function SAMLSignInPage() { // A forced re-auth leaves account.isLoading true until sign-in, so the token alone decides here. if (credentials?.login && shortLivedAuthToken) { Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...'); - signInWithShortLivedAuthToken(shortLivedAuthToken, true, lastVisitedPath); + signInWithShortLivedAuthToken(shortLivedAuthToken, true, lastVisitedPath, credentials?.login); return; } diff --git a/src/pages/tasks/DynamicNewTaskDetailsPage.tsx b/src/pages/tasks/DynamicNewTaskDetailsPage.tsx index 61fb0431626e..e236b1e227a2 100644 --- a/src/pages/tasks/DynamicNewTaskDetailsPage.tsx +++ b/src/pages/tasks/DynamicNewTaskDetailsPage.tsx @@ -13,6 +13,7 @@ import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; import {addErrorMessage} from '@libs/ErrorUtils'; @@ -28,7 +29,6 @@ import {createTaskAndNavigate, dismissModalAndClearOutTaskInfo, setDetailsValue, import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import {personalDetailsListSelector} from '@src/selectors/PersonalDetails'; import INPUT_IDS from '@src/types/form/NewTaskForm'; import React, {useState} from 'react'; @@ -41,9 +41,7 @@ function DynamicNewTaskDetailsPage() { const ancestors = useAncestors(parentReport); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); - const [taskCreatorAndAssigneeDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - selector: personalDetailsListSelector([currentUserPersonalDetails.accountID, task?.assigneeAccountID]), - }); + const [taskCreatorAndAssigneeDetails] = usePersonalDetailsByIDs([currentUserPersonalDetails.accountID, task?.assigneeAccountID]); const styles = useThemeStyles(); const {translate} = useLocalize(); const accountIDToName = useAccountIDToNameMap(); diff --git a/src/pages/tasks/DynamicNewTaskPage.tsx b/src/pages/tasks/DynamicNewTaskPage.tsx index 74d792a1a7b4..61a79f77aa27 100644 --- a/src/pages/tasks/DynamicNewTaskPage.tsx +++ b/src/pages/tasks/DynamicNewTaskPage.tsx @@ -6,8 +6,8 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import {useMenuItemConfig, useMenuItemInteraction} from '@components/MenuItem/MenuItemContext'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import MenuItemFieldHTML from '@components/MenuItem/presets/MenuItemFieldHTML'; import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ReportActionAvatars from '@components/ReportActionAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -19,6 +19,7 @@ import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; import usePressLoading from '@hooks/usePressLoading'; import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; @@ -29,6 +30,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {createTaskAndNavigate, dismissModalAndClearOutTaskInfo, getAssignee, getShareDestination, setShareDestinationValue} from '@libs/actions/Task'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; +import Parser from '@libs/Parser'; +import {getPersonalDetailsListByIDs} from '@libs/PersonalDetailsUtils'; import {isAllowedToComment} from '@libs/ReportUtils'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; @@ -36,7 +39,6 @@ import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import {personalDetailsListSelector} from '@src/selectors/PersonalDetails'; import {pendingDeleteMemberAccountIDsSelector} from '@src/selectors/ReportMetaData'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -72,16 +74,14 @@ function DynamicNewTaskPage() { const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${task?.shareDestination}`); const [pendingDeleteMemberAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${task?.shareDestination}`, {selector: pendingDeleteMemberAccountIDsSelector}); const policy = usePolicy(parentReport?.policyID); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [personalDetails] = useAllPersonalDetails(); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const derivedSharedDestinationReportName = useDerivedReportNameByReportID(parentReport?.reportID); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); - const [taskCreatorAndAssigneeDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - selector: personalDetailsListSelector([currentUserPersonalDetails.accountID, task?.assigneeAccountID]), - }); + const taskCreatorAndAssigneeDetails = getPersonalDetailsListByIDs([currentUserPersonalDetails.accountID, task?.assigneeAccountID], personalDetails); const styles = useThemeStyles(); const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const assignee = getAssignee(task?.assigneeAccountID ?? CONST.DEFAULT_NUMBER_ID, personalDetails, translate, formatPhoneNumber); @@ -211,23 +211,17 @@ function DynamicNewTaskPage() { > - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_TITLE.path))} - shouldShowRightIcon - rightLabel={translate('common.required')} - shouldParseTitle - excludedMarkdownRules={[...CONST.TASK_TITLE_DISABLED_RULES]} - /> - + {!task?.title && {translate('common.required')}} + + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_DESCRIPTION.path))} - shouldShowRightIcon - shouldParseTitle - numberOfLinesTitle={2} - titleStyle={styles.flex1} /> {assignee?.displayName ? ( - + + {(!isOffline || isDownloading) && ( + + )} + diff --git a/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx b/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx index 799b3b6e4c1a..953dedede905 100644 --- a/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx +++ b/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx @@ -142,6 +142,8 @@ function ConnectExistingBusinessBankAccountPage({route}: ConnectExistingBusiness excludeStates={[CONST.BANK_ACCOUNT.STATE.LOCKED]} excludeBankAccountID={isChangingBankAccount ? connectedAccountBankAccountID : undefined} shouldHideDefaultBadge + // The workspace bank account flow asks for the magic code itself and keeps the policyID after validation + shouldSkipDefaultAccountValidation /> )} diff --git a/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx b/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx index 1fb432052252..0be218ca901a 100644 --- a/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx +++ b/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx @@ -12,15 +12,15 @@ import useOnyx from '@hooks/useOnyx'; import usePrivateSubscription from '@hooks/usePrivateSubscription'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {createWorkspaceWithPolicyDraftAndNavigateToIt} from '@libs/actions/App'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation from '@libs/Navigation/Navigation'; import {isSubscriptionTypeOfInvoicing} from '@libs/SubscriptionUtils'; +import {createWorkspaceWithPolicyDraftAndNavigateToIt} from '@userActions/Policy/CreateWorkspaceFlow'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type {LastPaymentMethodType} from '@src/types/onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import React, {useState} from 'react'; @@ -31,7 +31,6 @@ function DynamicWorkspaceConfirmationPage() { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_CONFIRMATION.path); - const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -72,7 +71,6 @@ function DynamicWorkspaceConfirmationPage() { currency: params.currency, file: params.avatarFile as File, routeToNavigateAfterCreate: routeToNavigate, - lastUsedPaymentMethod: lastPaymentMethod?.[policyID] as LastPaymentMethodType, activePolicy, conciergeChat, currentUserAccountIDParam: currentUserPersonalDetails.accountID, diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 5beb127fe3d2..d3f3335608bd 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -26,6 +26,7 @@ import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import {isConnectionInProgress} from '@libs/actions/connections'; import {clearErrors, openPolicyInitialPage, removeWorkspace} from '@libs/actions/Policy/Policy'; +import {getRules} from '@libs/actions/Policy/Rules'; import goBackFromWorkspaceSettingPages from '@libs/Navigation/helpers/goBackFromWorkspaceSettingPages'; import WorkspaceCreationReveal from '@libs/Navigation/helpers/WorkspaceCreationReveal'; import Navigation from '@libs/Navigation/Navigation'; @@ -44,6 +45,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {LayoutChangeEvent} from 'react-native'; import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigationState} from '@react-navigation/native'; +import {createHasExpenseDefaultRuleErrorsSelector} from '@selectors/Rule'; import {emailSelector} from '@selectors/Session'; import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; @@ -87,6 +89,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${routePolicyID}`); + const [hasMerchantRuleErrors] = useOnyx(ONYXKEYS.COLLECTION.RULE, {selector: createHasExpenseDefaultRuleErrorsSelector(policyID)}); const workspaceAccountID = useWorkspaceAccountID(policyID); const {shouldShowEnterCredentialsError} = useGetReceiptPartnersIntegrationData(policyID); const {shouldShowRbrForWorkspaceAccountID} = useCardFeedErrors(); @@ -148,6 +151,9 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac return; } openPolicyInitialPage(routePolicyID); + // Deliberately not `useRulesPrefetch`, which fetches once per session for screens that only need a count. + // Opening a workspace is the point at which its rules have to be current, including after a reconnect. + getRules(); }; useNetwork({onReconnect: fetchPolicyData}); useFocusEffect( @@ -180,6 +186,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac icons: expensifyIcons, isConnectionInProgress: isConnectionInProgress(connectionSyncProgress, policy), policyCategories, + hasMerchantRuleErrors, previousPendingFields: prevPendingFields, shouldShowEnterCredentialsError, shouldShowRBR, diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index f1f05138fc85..c63e5edf8e9e 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -32,6 +32,7 @@ import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {isConnectionInProgress, syncConnection} from '@libs/actions/connections'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {updateMemberRoleInline} from '@libs/actions/Policy/InlineEdit'; import { clearAddMemberError, clearDeleteMemberError, @@ -54,11 +55,13 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {isPersonalDetailsReady} from '@libs/OptionsListUtils'; import {getPersonalDetailsByID, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {isPolicyReimburser} from '@libs/PolicyMemberRoleUtils'; import { canEditWorkspaceSettings as canEditWorkspaceSettingsUtil, canMemberAssignRole, canMemberManageMemberWithRole, canMemberWrite, + canRolePay, getConnectionExporters, getMemberAccountIDsForWorkspace, getReimburserEmail, @@ -69,6 +72,7 @@ import { isPaidGroupPolicy, isPolicyApprover, isSubmitPolicy, + PAYER_ROLES, shouldFilterExpensifyTeam, } from '@libs/PolicyUtils'; import {getDisplayNameForParticipant, isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; @@ -85,7 +89,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {PersonalDetails, PolicyEmployee} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; import type {ValueOf} from 'type-fest'; @@ -329,6 +333,24 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers [route.params.policyID], ); + const changeMemberRole = (login: string, accountID: number, currentRole: string | undefined, newRole: ValueOf) => { + if (newRole === currentRole || !canMemberAssignRole(policy, currentUserLogin ?? '', newRole)) { + return; + } + + // A reimburser must stay a valid payer, so reject any role that cannot pay. + if (getReimburserEmail(policy) === login && !canRolePay(newRole)) { + return; + } + + if (newRole !== CONST.POLICY.ROLE.ADMIN && isRuleBotEnforcingRules(accountID, policy)) { + showRuleBotGuardModal('changeRole', policyID); + return; + } + + updateMemberRoleInline(policy, login, accountID, currentRole, newRole); + }; + const policyOwner = policy?.owner; const canAssignElevatedRoles = canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.ASSIGN_ELEVATED_ROLES); const invitedPrimaryToSecondaryLogins = useMemo(() => invertObject(policy?.primaryLoginsInvited ?? {}), [policy?.primaryLoginsInvited]); @@ -339,7 +361,10 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const result: Array<{email: string; policyEmployee: PolicyEmployee; accountID: number; details: PersonalDetails}> = []; for (const [email, policyEmployee] of Object.entries(policy?.employeeList ?? {})) { - if (isDeletedPolicyEmployee(policyEmployee, isOffline)) { + // Inviting a secondary login leaves an empty employeeList entry: the backend nulls that key, then + // successData merges {pendingAction: null} back onto it. Skip it so it doesn't render as a second + // member. A real member whose personal details haven't loaded still has a role and stays visible. + if (isEmptyValueObject(policyEmployee) || isDeletedPolicyEmployee(policyEmployee, isOffline)) { continue; } @@ -379,15 +404,36 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers // Submit workspaces have a flat role model where every member, including the owner, is an Editor. const isSubmitWorkspace = isSubmitPolicy(policy); + const isSelectionModeActive = selectedEmployees.length > 0 || isMobileSelectionModeEnabled; + // Role assignment is a Collect/Control capability. Submit locks every member to Editor. + const canAssignMemberRole = isGroupPolicy(policy) && !isSubmitWorkspace; + const data: WorkspaceMemberRowData[] = useMemo(() => { const ownerDisplayRole = isSubmitWorkspace ? CONST.POLICY.ROLE.EDITOR : CONST.POLICY.ROLE.OWNER; + const assignablePayerRoles = PAYER_ROLES.filter((payerRole) => canMemberAssignRole(policy, currentUserLogin ?? '', payerRole)); + const policyRoles = Object.values(CONST.POLICY.ROLE); + return filteredMembers.map(({policyEmployee, accountID, details}) => { const isPendingDeleteOrError = canEditWorkspaceSettings && (policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(policyEmployee.errors)); - const role = policy?.owner === details.login ? ownerDisplayRole : policyEmployee.role; + const employeeRole = policyRoles.find((policyRole) => policyRole === policyEmployee.role); + const role = policy?.owner === details.login ? ownerDisplayRole : employeeRole; const login = details.login ?? ''; const memberEmail = formatPhoneNumber(login); const memberName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: details, translate, formatPhoneNumber}); + const isOwner = policy?.owner === login; + const isCurrentUser = accountID === session?.accountID; + const isReimburser = isPolicyReimburser(policy, login); + const canReimburserChangeRole = assignablePayerRoles.some((payerRole) => payerRole !== policyEmployee.role); + const canEditRole = + canAssignMemberRole && + !isSelectionModeActive && + !isPendingDeleteOrError && + !isOwner && + !isCurrentUser && + !details.isOptimisticPersonalDetail && + canMemberAssignRole(policy, currentUserLogin ?? '', policyEmployee.role) && + (!isReimburser || canReimburserChangeRole); return { keyForList: login, @@ -409,6 +455,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers errors: getLatestErrorMessageField(policyEmployee), pendingAction: policyEmployee.pendingAction, disabled: isPendingDeleteOrError, + canEditRole, + onChangeRole: (newRole) => changeMemberRole(login, accountID, policyEmployee.role, newRole), // Note which secondary login was used to invite this primary login invitedSecondaryLogin: details?.login ? (invitedPrimaryToSecondaryLogins[details.login] ?? '') : '', action: () => openMemberDetails(accountID, login), @@ -419,9 +467,11 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers filteredMembers, canEditWorkspaceSettings, canWriteMembers, + canAssignMemberRole, currentUserLogin, policy, isSubmitWorkspace, + isSelectionModeActive, formatPhoneNumber, translate, session?.accountID, @@ -430,6 +480,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers invitedPrimaryToSecondaryLogins, openMemberDetails, dismissError, + changeMemberRole, ]); useEffect(() => { diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index 43d1d892c8cf..8ac71ac5deb8 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -14,6 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import usePolicyConnectionsPrefetch from '@hooks/usePolicyConnectionsPrefetch'; import usePolicyData from '@hooks/usePolicyData'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; @@ -138,6 +139,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID.toString()}_${CONST.EXPENSIFY_CARD.BANK}`, { @@ -173,7 +175,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro // // Use the active vendor source so a stale GA connection cannot bypass the beta for another // integration. When no source is active, keep the connected integration's discovery row. - // QBO (R1), Sage Intacct (R2), Rillet, and DualEntry are GA. Xero and Business Central require the vendorMatching beta. + // QBO (R1), Sage Intacct (R2), Xero (R3), Rillet, and DualEntry are GA. Business Central requires the vendorMatching beta. // Certinia is deliberately not in the discovery list. It reaches the row only through // getActiveVendorMatchingIntegration's strict FFA gate, so PSA connections never see it. const vendorMatchingConnection = @@ -189,6 +191,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const isGenerallyAvailableVendorConnection = vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.QBO || vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT || + vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.XERO || vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.RILLET || vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.DUALENTRY; const shouldShowVendorsFeature = isGenerallyAvailableVendorConnection || (!!isVendorMatchingBetaEnabled && !!vendorMatchingConnection); @@ -288,7 +291,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro if (action !== ModalActions.CONFIRM) { return; } - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: false}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); }; const promptDisableTravelViaInvoicing = async () => { @@ -314,7 +317,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro if (action !== ModalActions.CONFIRM) { return; } - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: false}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); }; const promptDisableSmartLimitForWorkflows = async () => { diff --git a/src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx b/src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx index 27b60a1a6efe..006a13bb7c3a 100644 --- a/src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx @@ -8,6 +8,7 @@ import useOnyx from '@hooks/useOnyx'; import useReviewWorkspaceSettingsTaskCompletion from '@hooks/useReviewWorkspaceSettingsTaskCompletion'; import useShouldBlockCurrencyChange from '@hooks/useShouldBlockCurrencyChange'; +import {getExpensifyCardEnrollmentRoute, isCurrencySupportedForECards} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -42,7 +43,10 @@ function WorkspaceOverviewCurrencyPage({policy}: WorkspaceOverviewCurrencyPagePr const route = useRoute>(); const {translate} = useLocalize(); const isForcedToChangeCurrency = !!route.params?.isForcedToChangeCurrency; + const shouldStartExpensifyCardEnrollment = !!route.params?.shouldStartExpensifyCardEnrollment; const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [supportedCountriesByCurrency] = useOnyx(ONYXKEYS.CARD_SUPPORTED_COUNTRIES); + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const shouldBlockCurrencyChange = useShouldBlockCurrencyChange(policy?.id); const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); @@ -54,6 +58,23 @@ function WorkspaceOverviewCurrencyPage({policy}: WorkspaceOverviewCurrencyPagePr updateGeneralSettings(policy, policy?.name ?? '', item.currencyCode, getReviewWorkspaceSettingsTaskCompletion()); clearCorpayBankAccountFields(); + const isUkEuCurrencySupported = isCurrencySupportedForECards(item.currencyCode); + const canEnrollNewCardProgram = item.currencyCode === CONST.CURRENCY.USD || isUkEuCurrencySupported; + if (shouldStartExpensifyCardEnrollment && canEnrollNewCardProgram) { + Navigation.navigate( + getExpensifyCardEnrollmentRoute({ + policyID: policy.id, + currencyCode: item.currencyCode, + isUkEuCurrencySupported, + bankAccountsList: bankAccountList, + supportedCountriesByCurrency, + achData: reimbursementAccount?.achData, + }), + {forceReplace: true}, + ); + return; + } + if (isForcedToChangeCurrency) { if (isCurrencySupportedForGlobalReimbursement(item.currencyCode as CurrencyType)) { const hasValidExistingAccounts = getEligibleExistingBusinessBankAccounts(bankAccountList, item.currencyCode, true).length > 0; @@ -103,4 +124,6 @@ function WorkspaceOverviewCurrencyPage({policy}: WorkspaceOverviewCurrencyPagePr ); } +export {WorkspaceOverviewCurrencyPage}; +export type {WorkspaceOverviewCurrencyPageProps}; export default withPolicyAndFullscreenLoading(WorkspaceOverviewCurrencyPage); diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 6ec96548d145..10233579782c 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -13,6 +13,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -85,7 +86,7 @@ function WorkspacesListPage() { .filter((id): id is number => id !== undefined), ), ]; - const [ownerDisplayDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: createDisplayDetailsByAccountIDsSelector(ownerAccountIDs)}); + const [ownerDisplayDetails] = useAllPersonalDetails(createDisplayDetailsByAccountIDsSelector(ownerAccountIDs)); const navigateToWorkspace = (policyID: string, event?: ModifiedMouseEvent) => { const workspaceRoute = shouldUseNarrowLayout ? ROUTES.WORKSPACE_INITIAL.getRoute(policyID) : ROUTES.WORKSPACE_OVERVIEW.getRoute(policyID); diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 57dcfe3870d4..9cfa34148979 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -36,9 +36,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; +import {getQBORefreshTokenExpiryDate, getQBORefreshTokenExpiryStatus} from '@libs/AccountingUtils'; import {isAuthenticationError, isConnectionInProgress, isConnectionUnverified, removePolicyConnection, syncConnection} from '@libs/actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; import {isExpensifyCardFullySetUp} from '@libs/CardUtils'; +import DateUtils from '@libs/DateUtils'; import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment'; import getPlatform from '@libs/getPlatform'; import { @@ -99,7 +101,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const theme = useTheme(); const styles = useThemeStyles(); - const {translate, datetimeToRelative: getDatetimeToRelative, getLocalDateFromDatetime} = useLocalize(); + const {translate, datetimeToRelative: getDatetimeToRelative, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); const {environment} = useEnvironment(); const oldDotEnvironmentURL = getOldDotURLFromEnvironment(environment); const {isOffline} = useNetwork(); @@ -174,7 +176,12 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const isSageIntacct = connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT; const hasAuthError = !!connectedIntegration && !!synchronizationError && isAuthenticationError(policy, connectedIntegration); - const shouldShowEnterCredentials = !!connectedIntegration && (hasAuthError || isSageIntacct); + // A QBO refresh token that is about to expire (or already has, without a sync failing yet) is warned about while the connection still looks healthy + const qboTokenExpiryStatus = + connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && !synchronizationError && !isSyncInProgress ? getQBORefreshTokenExpiryStatus(policy) : undefined; + const isQBOTokenExpiringSoon = !!qboTokenExpiryStatus; + const qboTokenExpiryDate = isQBOTokenExpiringSoon ? getQBORefreshTokenExpiryDate(policy) : undefined; + const shouldShowEnterCredentials = !!connectedIntegration && (hasAuthError || isSageIntacct || isQBOTokenExpiringSoon); // Get the last successful date of the integration. Then, if `connectionSyncProgress` is the same integration displayed and the state is 'jobDone', get the more recent update time of the two. const successfulDate = getIntegrationLastSuccessfulDate( @@ -193,7 +200,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const shouldShowCardReconciliationOption = Object.values(allCardSettings ?? {})?.some((cardSetting) => isExpensifyCardFullySetUp(policy, cardSetting)); const shouldShowReconnect = hasAuthError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.CERTINIA; let credentialsMenuTextKey: Parameters[0] = 'workspace.accounting.enterCredentials'; - if (shouldShowReconnect) { + if (shouldShowReconnect || isQBOTokenExpiringSoon) { credentialsMenuTextKey = 'workspace.accounting.reconnect'; } else if (isSageIntacct && !hasAuthError) { credentialsMenuTextKey = 'workspace.accounting.updateCredentials'; @@ -223,7 +230,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); return; } - startIntegrationFlow({name: connectedIntegration}); + startIntegrationFlow({name: connectedIntegration, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite}); }, shouldCallAfterModalHide: true, disabled: isOffline, @@ -274,6 +281,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { policy, connectedIntegration, connectedIntegrationDisplayName, + isConnectedToIntuitEnterpriseSuite, startIntegrationFlow, isSageIntacct, hasAuthError, @@ -735,6 +743,24 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ); } + let qboTokenExpiryHint; + if (qboTokenExpiryDate && canWriteAccounting) { + const formattedExpiryDate = DateUtils.formatWithUTCTimeZone(qboTokenExpiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, dateFnsLocale); + qboTokenExpiryHint = ( + <> + {translate( + qboTokenExpiryStatus === CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_STATUS.EXPIRED + ? 'workspace.accounting.qboConnectionExpired' + : 'workspace.accounting.qboConnectionExpiring', + {date: formattedExpiryDate}, + )}{' '} + startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBO, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite})}> + {translate('workspace.accounting.reconnect')} + + + ); + } + return [ { ...iconProps, @@ -746,6 +772,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { errorTextStyle: [styles.mt5], shouldShowRedDotIndicator: true, description: connectionMessage, + hintText: qboTokenExpiryHint, rightComponent, }, ...(isEmptyObject(integrationSpecificMenuItems) || shouldShowSynchronizationError || !hasAccountingConnection ? [] : [integrationSpecificMenuItems]), @@ -787,6 +814,9 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { startIntegrationFlow, popoverAnchorRefs, datetimeToRelative, + qboTokenExpiryDate, + qboTokenExpiryStatus, + dateFnsLocale, hasReusablePoliciesConnectedToSageIntacct, hasReusablePoliciesConnectedToCertinia, hasReusablePoliciesConnectedToRillet, diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralCompanyCardAccountSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralCompanyCardAccountSelectPage.tsx new file mode 100644 index 000000000000..eeb2a6271ee8 --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralCompanyCardAccountSelectPage.tsx @@ -0,0 +1,97 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useSelectionListSearch from '@hooks/useSelectionListSearch'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearBusinessCentralErrorField, updateBusinessCentralNonReimbursableAccount} from '@libs/actions/connections/BusinessCentral'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; +import {View} from 'react-native'; + +type AccountListItem = ListItem & { + value: string; +}; + +function BusinessCentralCompanyCardAccountSelectPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + const policyID = policy?.id; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const nonReimbursableAccount = businessCentralConfig?.export?.nonReimbursableAccount; + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT.getRoute(policyID) : undefined; + + const data: AccountListItem[] = + policy?.connections?.businessCentral?.data?.bankAccounts?.map((bankAccount) => ({ + value: bankAccount.id, + text: `${bankAccount.number} ${bankAccount.name}`, + keyForList: bankAccount.id, + isSelected: nonReimbursableAccount === bankAccount.id, + })) ?? []; + const {filteredData, textInputOptions} = useSelectionListSearch(data); + + const headerContent = ( + + {translate('workspace.businessCentral.companyCardAccount.description')} + + ); + + const listEmptyContent = ( + + ); + + const selectAccount = (item: AccountListItem) => { + if (item.value !== nonReimbursableAccount && policyID) { + updateBusinessCentralNonReimbursableAccount(policyID, item.value, nonReimbursableAccount); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.NON_REIMBURSABLE_ACCOUNT], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig, CONST.BUSINESS_CENTRAL_CONFIG.NON_REIMBURSABLE_ACCOUNT)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.NON_REIMBURSABLE_ACCOUNT)} + /> + ); +} + +export default withPolicyConnections(BusinessCentralCompanyCardAccountSelectPage); diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralDefaultVendorSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralDefaultVendorSelectPage.tsx new file mode 100644 index 000000000000..338fc50ba5e6 --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralDefaultVendorSelectPage.tsx @@ -0,0 +1,98 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useSelectionListSearch from '@hooks/useSelectionListSearch'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearBusinessCentralErrorField, updateBusinessCentralDefaultVendor} from '@libs/actions/connections/BusinessCentral'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction, sortVendors} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; +import {View} from 'react-native'; + +type VendorListItem = ListItem & { + value: string; +}; + +function BusinessCentralDefaultVendorSelectPage({policy}: WithPolicyConnectionsProps) { + const {translate, localeCompare} = useLocalize(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + const policyID = policy?.id; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const defaultVendorID = businessCentralConfig?.export?.defaultVendorID; + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT.getRoute(policyID) : undefined; + + // Business Central rejects documents with a vendor blocked for all transactions, so it can't be the fallback vendor + const vendors = (policy?.connections?.businessCentral?.data?.vendors ?? []).filter((vendor) => vendor.blocked !== CONST.BUSINESS_CENTRAL_VENDOR_BLOCKED.ALL); + const data: VendorListItem[] = sortVendors(vendors, localeCompare).map((vendor) => ({ + value: vendor.id, + text: vendor.name, + keyForList: vendor.id, + isSelected: defaultVendorID === vendor.id, + })); + const {filteredData, textInputOptions} = useSelectionListSearch(data); + + const headerContent = ( + + {translate('workspace.businessCentral.defaultCompanyCardVendor.description')} + + ); + + const listEmptyContent = ( + + ); + + const selectVendor = (item: VendorListItem) => { + if (item.value !== defaultVendorID && policyID) { + updateBusinessCentralDefaultVendor(policyID, item.value, defaultVendorID); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.DEFAULT_VENDOR_ID], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig, CONST.BUSINESS_CENTRAL_CONFIG.DEFAULT_VENDOR_ID)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.DEFAULT_VENDOR_ID)} + /> + ); +} + +export default withPolicyConnections(BusinessCentralDefaultVendorSelectPage); diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralExportDateSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralExportDateSelectPage.tsx new file mode 100644 index 000000000000..8518873f24b4 --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralExportDateSelectPage.tsx @@ -0,0 +1,78 @@ +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearBusinessCentralErrorField, updateBusinessCentralExportDate} from '@libs/actions/connections/BusinessCentral'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {BusinessCentralExport} from '@src/types/onyx/Policy'; + +import React from 'react'; +import {View} from 'react-native'; + +type ExportDateListItem = ListItem & { + value: BusinessCentralExport['exportDate']; +}; + +function BusinessCentralExportDateSelectPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const exportDate = businessCentralConfig?.export?.exportDate ?? CONST.BUSINESS_CENTRAL_EXPORT_DATE.LAST_EXPENSE; + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT.getRoute(policyID) : undefined; + + const data: ExportDateListItem[] = Object.values(CONST.BUSINESS_CENTRAL_EXPORT_DATE).map((exportDateItem) => ({ + value: exportDateItem, + text: translate(`workspace.businessCentral.exportDate.values.${exportDateItem}.label`), + alternateText: translate(`workspace.businessCentral.exportDate.values.${exportDateItem}.description`), + keyForList: exportDateItem, + isSelected: exportDate === exportDateItem, + })); + + const headerContent = ( + + {translate('workspace.businessCentral.exportDate.description')} + + ); + + const selectExportDate = (item: ExportDateListItem) => { + if (item.value !== exportDate && policyID) { + updateBusinessCentralExportDate(policyID, item.value, businessCentralConfig?.export?.exportDate); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.EXPORT_DATE], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig, CONST.BUSINESS_CENTRAL_CONFIG.EXPORT_DATE)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.EXPORT_DATE)} + /> + ); +} + +export default withPolicyConnections(BusinessCentralExportDateSelectPage); diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralExportPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralExportPage.tsx new file mode 100644 index 000000000000..9265ce487c2c --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralExportPage.tsx @@ -0,0 +1,157 @@ +import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; +import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; + +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; + +import CONST from '@src/CONST'; +import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import type {BusinessCentralExport} from '@src/types/onyx/Policy'; + +import React from 'react'; +import {View} from 'react-native'; + +type ExportRow = { + settingName: keyof BusinessCentralExport; + label: string; + value: string | undefined; + route: Route; + shouldHide?: boolean; +}; + +function BusinessCentralExportPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const businessCentralData = policy?.connections?.businessCentral?.data; + const exportConfig = businessCentralConfig?.export; + + // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string on a fresh connection + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const exporter = exportConfig?.exporter || policy?.owner; + const exportDate = exportConfig?.exportDate ?? CONST.BUSINESS_CENTRAL_EXPORT_DATE.LAST_EXPENSE; + const reimbursable = exportConfig?.reimbursable ?? CONST.BUSINESS_CENTRAL_EXPORT_DESTINATION.JOURNAL_ENTRY; + const nonReimbursable = exportConfig?.nonReimbursable ?? CONST.BUSINESS_CENTRAL_EXPORT_DESTINATION.PURCHASE_INVOICE; + const reimbursableAccount = businessCentralData?.bankAccounts?.find((bankAccount) => bankAccount.id === exportConfig?.reimbursableAccount); + const nonReimbursableAccount = businessCentralData?.bankAccounts?.find((bankAccount) => bankAccount.id === exportConfig?.nonReimbursableAccount); + const defaultVendor = businessCentralData?.vendors?.find((vendor) => vendor.id === exportConfig?.defaultVendorID); + const paymentMethod = businessCentralData?.paymentMethods?.find((method) => method.code === exportConfig?.paymentMethodCode); + const isReimbursableJournalEntry = reimbursable === CONST.BUSINESS_CENTRAL_EXPORT_DESTINATION.JOURNAL_ENTRY; + const isNonReimbursableJournalEntry = nonReimbursable === CONST.BUSINESS_CENTRAL_EXPORT_DESTINATION.JOURNAL_ENTRY; + + if (!policyID) { + return null; + } + + const sections: ExportRow[][] = [ + [ + { + settingName: CONST.BUSINESS_CENTRAL_CONFIG.EXPORTER, + label: translate('workspace.accounting.preferredExporter'), + value: exporter, + route: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_PREFERRED_EXPORTER.getRoute(policyID), + }, + { + settingName: CONST.BUSINESS_CENTRAL_CONFIG.EXPORT_DATE, + label: translate('workspace.businessCentral.exportDate.label'), + value: translate(`workspace.businessCentral.exportDate.values.${exportDate}.label`), + route: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_DATE.getRoute(policyID), + }, + ], + [ + { + settingName: CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSABLE, + label: translate('workspace.businessCentral.exportReimbursable'), + value: translate(`workspace.businessCentral.exportDestination.${reimbursable}`), + route: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION.getRoute(policyID), + }, + { + settingName: CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSABLE_ACCOUNT, + label: translate('workspace.businessCentral.reimbursableAccount.label'), + value: reimbursableAccount ? `${reimbursableAccount.number} ${reimbursableAccount.name}` : undefined, + route: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_REIMBURSABLE_ACCOUNT.getRoute(policyID), + shouldHide: !isReimbursableJournalEntry, + }, + ], + [ + { + settingName: CONST.BUSINESS_CENTRAL_CONFIG.NON_REIMBURSABLE, + label: translate('workspace.businessCentral.exportNonReimbursable'), + value: translate(`workspace.businessCentral.exportDestination.${nonReimbursable}`), + route: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_NONREIMBURSABLE_EXPENSES_EXPORT_DESTINATION.getRoute(policyID), + }, + { + settingName: CONST.BUSINESS_CENTRAL_CONFIG.DEFAULT_VENDOR_ID, + label: translate('workspace.businessCentral.defaultCompanyCardVendor.label'), + value: defaultVendor?.name, + route: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_DEFAULT_VENDOR.getRoute(policyID), + }, + { + settingName: CONST.BUSINESS_CENTRAL_CONFIG.NON_REIMBURSABLE_ACCOUNT, + label: translate('workspace.businessCentral.companyCardAccount.label'), + value: nonReimbursableAccount ? `${nonReimbursableAccount.number} ${nonReimbursableAccount.name}` : undefined, + route: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_COMPANY_CARD_ACCOUNT.getRoute(policyID), + shouldHide: !isNonReimbursableJournalEntry, + }, + { + settingName: CONST.BUSINESS_CENTRAL_CONFIG.PAYMENT_METHOD_CODE, + label: translate('workspace.businessCentral.paymentMethod.label'), + value: paymentMethod?.displayName, + route: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_PAYMENT_METHOD.getRoute(policyID), + shouldHide: isReimbursableJournalEntry && isNonReimbursableJournalEntry, + }, + ], + ]; + + return ( + + {translate('workspace.businessCentral.exportDescription')} + {sections.map((rows, sectionIndex) => ( + + {sectionIndex > 0 && } + {rows + .filter((row) => !row.shouldHide) + .map((row) => ( + + Navigation.navigate(row.route)} + > + {areSettingsInErrorFields([row.settingName], businessCentralConfig?.errorFields) && ( + + )} + + + ))} + + ))} + + ); +} + +export default withPolicyConnections(BusinessCentralExportPage); diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralNonReimbursableExpensesExportDestinationSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralNonReimbursableExpensesExportDestinationSelectPage.tsx new file mode 100644 index 000000000000..28c8c4b7cff0 --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralNonReimbursableExpensesExportDestinationSelectPage.tsx @@ -0,0 +1,68 @@ +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearBusinessCentralErrorField, updateBusinessCentralNonReimbursableExpensesExportDestination} from '@libs/actions/connections/BusinessCentral'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {BusinessCentralExport} from '@src/types/onyx/Policy'; + +import React from 'react'; + +type DestinationListItem = ListItem & { + value: BusinessCentralExport['nonReimbursable']; +}; + +function BusinessCentralNonReimbursableExpensesExportDestinationSelectPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const nonReimbursable = businessCentralConfig?.export?.nonReimbursable ?? CONST.BUSINESS_CENTRAL_EXPORT_DESTINATION.PURCHASE_INVOICE; + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT.getRoute(policyID) : undefined; + + const data: DestinationListItem[] = Object.values(CONST.BUSINESS_CENTRAL_EXPORT_DESTINATION).map((destination) => ({ + value: destination, + text: translate(`workspace.businessCentral.exportDestination.${destination}`), + keyForList: destination, + isSelected: nonReimbursable === destination, + })); + + const selectDestination = (item: DestinationListItem) => { + if (item.value !== nonReimbursable && policyID) { + updateBusinessCentralNonReimbursableExpensesExportDestination(policyID, item.value, businessCentralConfig?.export?.nonReimbursable); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.NON_REIMBURSABLE], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig, CONST.BUSINESS_CENTRAL_CONFIG.NON_REIMBURSABLE)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.NON_REIMBURSABLE)} + /> + ); +} + +export default withPolicyConnections(BusinessCentralNonReimbursableExpensesExportDestinationSelectPage); diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPaymentMethodSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPaymentMethodSelectPage.tsx new file mode 100644 index 000000000000..2c5264566764 --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPaymentMethodSelectPage.tsx @@ -0,0 +1,106 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useSelectionListSearch from '@hooks/useSelectionListSearch'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearBusinessCentralErrorField, updateBusinessCentralPaymentMethod} from '@libs/actions/connections/BusinessCentral'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; +import {View} from 'react-native'; + +type PaymentMethodListItem = ListItem & { + value: string; +}; + +function BusinessCentralPaymentMethodSelectPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + const policyID = policy?.id; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const paymentMethodCode = businessCentralConfig?.export?.paymentMethodCode; + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT.getRoute(policyID) : undefined; + + const paymentMethodOptions: PaymentMethodListItem[] = + policy?.connections?.businessCentral?.data?.paymentMethods?.map((paymentMethod) => ({ + value: paymentMethod.code, + text: paymentMethod.displayName, + keyForList: paymentMethod.code, + isSelected: paymentMethodCode === paymentMethod.code, + })) ?? []; + const {filteredData, textInputOptions} = useSelectionListSearch(paymentMethodOptions); + + // The payment method is optional, and an empty code clears it + const noneOption: PaymentMethodListItem = { + value: '', + text: translate('common.none'), + keyForList: '', + isSelected: !paymentMethodCode, + }; + const data = paymentMethodOptions.length > 0 ? [noneOption, ...filteredData] : []; + + const headerContent = ( + + {translate('workspace.businessCentral.paymentMethod.description')} + + ); + + const listEmptyContent = ( + + ); + + const selectPaymentMethod = (item: PaymentMethodListItem) => { + if (item.value !== (paymentMethodCode ?? '') && policyID) { + updateBusinessCentralPaymentMethod(policyID, item.value, paymentMethodCode); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.PAYMENT_METHOD_CODE], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig, CONST.BUSINESS_CENTRAL_CONFIG.PAYMENT_METHOD_CODE)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.PAYMENT_METHOD_CODE)} + /> + ); +} + +export default withPolicyConnections(BusinessCentralPaymentMethodSelectPage); diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPreferredExporterSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPreferredExporterSelectPage.tsx new file mode 100644 index 000000000000..21e5b4e56c89 --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPreferredExporterSelectPage.tsx @@ -0,0 +1,108 @@ +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearBusinessCentralErrorField, updateBusinessCentralExporter} from '@libs/actions/connections/BusinessCentral'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {getAdminEmployees, isExpensifyTeam, settingsPendingAction} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import isEmpty from 'lodash/isEmpty'; +import React from 'react'; +import {View} from 'react-native'; + +type ExporterListItem = ListItem & { + value: string; +}; + +function BusinessCentralPreferredExporterSelectPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + const policyID = policy?.id; + const policyOwner = policy?.owner; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const savedExporter = businessCentralConfig?.export?.exporter; + + // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string on a fresh connection + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const exporter = savedExporter || policyOwner; + const exporters = getAdminEmployees(policy); + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT.getRoute(policyID) : undefined; + + const data: ExporterListItem[] = + policyOwner && isEmpty(exporters) + ? [ + { + value: policyOwner, + text: policyOwner, + keyForList: policyOwner, + isSelected: exporter === policyOwner, + }, + ] + : (exporters?.reduce((options, exporterItem) => { + if (!exporterItem.email) { + return options; + } + + // Don't show guides if the current user is not a guide themselves or an Expensify employee + if (isExpensifyTeam(exporterItem.email) && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin)) { + return options; + } + + options.push({ + value: exporterItem.email, + text: exporterItem.email, + keyForList: exporterItem.email, + isSelected: exporter === exporterItem.email, + }); + return options; + }, []) ?? []); + + const headerContent = ( + + {translate('workspace.accounting.exportPreferredExporterNote')} + {translate('workspace.accounting.exportPreferredExporterSubNote')} + + ); + + const selectExporter = (item: ExporterListItem) => { + if (item.value !== exporter && policyID) { + updateBusinessCentralExporter(policyID, item.value, savedExporter); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.EXPORTER], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig, CONST.BUSINESS_CENTRAL_CONFIG.EXPORTER)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.EXPORTER)} + /> + ); +} + +export default withPolicyConnections(BusinessCentralPreferredExporterSelectPage); diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralReimbursableAccountSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralReimbursableAccountSelectPage.tsx new file mode 100644 index 000000000000..b5def4f6d641 --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralReimbursableAccountSelectPage.tsx @@ -0,0 +1,97 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useSelectionListSearch from '@hooks/useSelectionListSearch'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearBusinessCentralErrorField, updateBusinessCentralReimbursableAccount} from '@libs/actions/connections/BusinessCentral'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; +import {View} from 'react-native'; + +type AccountListItem = ListItem & { + value: string; +}; + +function BusinessCentralReimbursableAccountSelectPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + const policyID = policy?.id; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const reimbursableAccount = businessCentralConfig?.export?.reimbursableAccount; + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT.getRoute(policyID) : undefined; + + const data: AccountListItem[] = + policy?.connections?.businessCentral?.data?.bankAccounts?.map((bankAccount) => ({ + value: bankAccount.id, + text: `${bankAccount.number} ${bankAccount.name}`, + keyForList: bankAccount.id, + isSelected: reimbursableAccount === bankAccount.id, + })) ?? []; + const {filteredData, textInputOptions} = useSelectionListSearch(data); + + const headerContent = ( + + {translate('workspace.businessCentral.reimbursableAccount.description')} + + ); + + const listEmptyContent = ( + + ); + + const selectAccount = (item: AccountListItem) => { + if (item.value !== reimbursableAccount && policyID) { + updateBusinessCentralReimbursableAccount(policyID, item.value, reimbursableAccount); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSABLE_ACCOUNT], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig, CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSABLE_ACCOUNT)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSABLE_ACCOUNT)} + /> + ); +} + +export default withPolicyConnections(BusinessCentralReimbursableAccountSelectPage); diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralReimbursableExpensesExportDestinationSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralReimbursableExpensesExportDestinationSelectPage.tsx new file mode 100644 index 000000000000..bc8dce748272 --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralReimbursableExpensesExportDestinationSelectPage.tsx @@ -0,0 +1,68 @@ +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearBusinessCentralErrorField, updateBusinessCentralReimbursableExpensesExportDestination} from '@libs/actions/connections/BusinessCentral'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {BusinessCentralExport} from '@src/types/onyx/Policy'; + +import React from 'react'; + +type DestinationListItem = ListItem & { + value: BusinessCentralExport['reimbursable']; +}; + +function BusinessCentralReimbursableExpensesExportDestinationSelectPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const reimbursable = businessCentralConfig?.export?.reimbursable ?? CONST.BUSINESS_CENTRAL_EXPORT_DESTINATION.JOURNAL_ENTRY; + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT.getRoute(policyID) : undefined; + + const data: DestinationListItem[] = Object.values(CONST.BUSINESS_CENTRAL_EXPORT_DESTINATION).map((destination) => ({ + value: destination, + text: translate(`workspace.businessCentral.exportDestination.${destination}`), + keyForList: destination, + isSelected: reimbursable === destination, + })); + + const selectDestination = (item: DestinationListItem) => { + if (item.value !== reimbursable && policyID) { + updateBusinessCentralReimbursableExpensesExportDestination(policyID, item.value, businessCentralConfig?.export?.reimbursable); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSABLE], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig, CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSABLE)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSABLE)} + /> + ); +} + +export default withPolicyConnections(BusinessCentralReimbursableExpensesExportDestinationSelectPage); diff --git a/src/pages/workspace/accounting/campfire/export/CampfireCardAccountCardListPage.tsx b/src/pages/workspace/accounting/campfire/export/CampfireCardAccountCardListPage.tsx new file mode 100644 index 000000000000..30e57bcb7c74 --- /dev/null +++ b/src/pages/workspace/accounting/campfire/export/CampfireCardAccountCardListPage.tsx @@ -0,0 +1,115 @@ +import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Text from '@components/Text'; + +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsList from '@hooks/useCardsList'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {areCardsCustomExportInErrorFields, findMatchingCards, getCardsCustomExportPendingAction} from '@libs/CardFeedUtils'; +import {getCardDescription, getCustomOrFormattedFeedName, isCard} from '@libs/CardUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; + +import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; +import {View} from 'react-native'; + +type CampfireCardAccountCardListPageProps = WithPolicyConnectionsProps & + PlatformStackScreenProps; + +function CampfireCardAccountCardListPage({ + policy, + route: { + params: {feed: feedWithDomainID}, + }, +}: CampfireCardAccountCardListPageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const [cardList] = useCardsList(feedWithDomainID); + const [cardFeeds] = useCardFeeds(policyID); + const cardFeed = cardFeeds?.[feedWithDomainID]; + const feedKey = cardFeed?.feed; + const campfireConfig = policy?.connections?.campfire?.config; + const campfireData = policy?.connections?.campfire?.data; + const creditCardAccountID = campfireConfig?.export?.creditCardAccountID; + const cardProgramsUsingCustomAccounts = campfireConfig?.export?.cardProgramAccounts; + const cardProgramAccountID = (feedKey ? cardProgramsUsingCustomAccounts?.[feedKey] : undefined) ?? creditCardAccountID; + const cardProgramAccount = campfireData?.accounts?.find((account) => account.id === cardProgramAccountID); + const hasActiveCards = feedKey && findMatchingCards(cardFeeds ?? {}, {[feedWithDomainID]: cardList}, feedKey).length > 0; + const title = getCustomOrFormattedFeedName(translate, feedKey, cardFeed?.customFeedName, false); + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_CAMPFIRE_CARD_ACCOUNT.getRoute(policyID) : undefined; + + return ( + Navigation.goBack(backPath)} + shouldBeBlocked + shouldBeForceBlocked={!hasActiveCards} + > + + {translate('workspace.campfire.cardAccount.descriptionLevel2')} + + {Object.values(cardList ?? {}) + .filter(isCard) + .map((card) => { + const cardID = card.cardID; + const isUsingCustomAccount = typeof card.nameValuePairs === 'object' && CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT in card.nameValuePairs; + const cardAccountID = + (typeof card.nameValuePairs === 'object' ? card.nameValuePairs[CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT] : undefined) ?? + cardProgramAccount?.id; + const cardAccount = campfireData?.accounts?.find((account) => account.id === cardAccountID); + const cardAccountDisplayName = cardAccount + ? `${cardAccount.id} ${cardAccount.name}${isUsingCustomAccount ? '' : ` (${translate('common.default').toLocaleLowerCase()})`}` + : ''; + return ( + + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_COMPANY_CARD_EXPORT.getRoute(feedWithDomainID, String(cardID))))} + value={cardAccountDisplayName} + > + {areCardsCustomExportInErrorFields( + cardFeeds ?? {}, + {[feedWithDomainID]: cardList}, + CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT, + feedKey, + cardID, + ) && } + + + ); + })} + + ); +} + +export default withPolicyConnections(CampfireCardAccountCardListPage); diff --git a/src/pages/workspace/accounting/campfire/export/CampfireCardAccountPage.tsx b/src/pages/workspace/accounting/campfire/export/CampfireCardAccountPage.tsx new file mode 100644 index 000000000000..6ec1ca037cc0 --- /dev/null +++ b/src/pages/workspace/accounting/campfire/export/CampfireCardAccountPage.tsx @@ -0,0 +1,99 @@ +import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Text from '@components/Text'; + +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsLists from '@hooks/useCardsLists'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {areCardsCustomExportInErrorFields, findMatchingCards, getCardsCustomExportPendingAction, getCardsUsingCustomExportCount} from '@libs/CardFeedUtils'; +import {getCardFeedWithDomainID, getCustomOrFormattedFeedName} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; + +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; +import {View} from 'react-native'; + +function CampfireCardAccountPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); + const campfireConfig = policy?.connections?.campfire?.config; + const campfireData = policy?.connections?.campfire?.data; + const creditCardAccountID = campfireConfig?.export?.creditCardAccountID; + const cardProgramsUsingCustomAccounts = campfireConfig?.export?.cardProgramAccounts; + const cardsUsingCustomAccountsCount = getCardsUsingCustomExportCount(cardFeeds ?? {}, cardLists, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT); + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_CAMPFIRE_EXPORT.getRoute(policyID) : undefined; + + return ( + Navigation.goBack(backPath)} + shouldBeBlocked + > + + {translate('workspace.campfire.cardAccount.description')} + + {Object.values(cardFeeds ?? {}) + .filter((cardFeed) => findMatchingCards(cardFeeds ?? {}, cardLists, cardFeed.feed).length > 0) + .map((cardFeed) => { + const feedKey = cardFeed.feed; + const feedName = getCustomOrFormattedFeedName(translate, feedKey, cardFeed.customFeedName, false); + const feedDomainID = cardFeed.domainID ?? CONST.DEFAULT_MISSING_ID; + const feedWithDomainID = getCardFeedWithDomainID(feedKey, feedDomainID); + const cardProgramAccountID = cardProgramsUsingCustomAccounts?.[feedKey] ?? creditCardAccountID; + const isUsingDefaultAccount = cardProgramAccountID === creditCardAccountID; + const cardProgramAccount = campfireData?.accounts?.find((account) => account.id === cardProgramAccountID); + const cardProgramAccountDisplayName = cardProgramAccount + ? `${cardProgramAccount.id} ${cardProgramAccount.name}${isUsingDefaultAccount ? ` (${translate('common.default').toLocaleLowerCase()})` : ''}` + : ''; + return ( + + + policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_CARD_ACCOUNT_CARD_LIST.getRoute(policyID, feedWithDomainID)) : undefined, + )} + > + + {areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT, feedKey) && ( + + )} + + + {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( + + )} + + + ); + })} + + ); +} + +export default withPolicyConnections(CampfireCardAccountPage); diff --git a/src/pages/workspace/accounting/campfire/export/CampfireCardProgramAccountPage.tsx b/src/pages/workspace/accounting/campfire/export/CampfireCardProgramAccountPage.tsx new file mode 100644 index 000000000000..42ab275935fb --- /dev/null +++ b/src/pages/workspace/accounting/campfire/export/CampfireCardProgramAccountPage.tsx @@ -0,0 +1,100 @@ +import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Text from '@components/Text'; + +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsLists from '@hooks/useCardsLists'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {findMatchingCards, getCardsUsingCustomExportCount} from '@libs/CardFeedUtils'; +import {getCardFeedWithDomainID, getCustomOrFormattedFeedName} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; + +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; + +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; +import {View} from 'react-native'; + +function CampfireCardProgramAccountPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); + const campfireConfig = policy?.connections?.campfire?.config; + const campfireData = policy?.connections?.campfire?.data; + const creditCardAccountID = campfireConfig?.export?.creditCardAccountID; + const cardProgramsUsingCustomAccounts = campfireConfig?.export?.cardProgramAccounts; + const cardsUsingCustomAccountsCount = getCardsUsingCustomExportCount(cardFeeds ?? {}, cardLists, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT); + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_CAMPFIRE_EXPORT.getRoute(policyID) : undefined; + + return ( + Navigation.goBack(backPath)} + shouldBeBlocked + > + + {translate('workspace.campfire.cardProgramAccount.description')} + + {Object.values(cardFeeds ?? {}) + .filter((cardFeed) => findMatchingCards(cardFeeds ?? {}, cardLists, cardFeed.feed).length > 0) + .map((cardFeed) => { + const feedKey = cardFeed.feed; + const feedName = getCustomOrFormattedFeedName(translate, feedKey, cardFeed.customFeedName, false); + const feedDomainID = cardFeed.domainID ?? CONST.DEFAULT_MISSING_ID; + const feedWithDomainID = getCardFeedWithDomainID(feedKey, feedDomainID); + const isUsingCustomAccount = !!cardProgramsUsingCustomAccounts?.[feedKey]; + const cardProgramAccountID = cardProgramsUsingCustomAccounts?.[feedKey] ?? creditCardAccountID; + const cardProgramAccount = campfireData?.accounts?.find((account) => account.id === cardProgramAccountID); + const cardProgramAccountDisplayName = cardProgramAccount + ? `${cardProgramAccount.id} ${cardProgramAccount.name}${isUsingCustomAccount ? '' : ` (${translate('common.default').toLocaleLowerCase()})`}` + : ''; + return ( + + + policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_CARD_PROGRAM_ACCOUNT_SELECTOR.getRoute(policyID, feedWithDomainID)) : undefined, + )} + > + + {areSettingsInErrorFields([`${CONST.CAMPFIRE_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], campfireConfig?.errorFields) && ( + + )} + + + {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( + + )} + + + ); + })} + + ); +} + +export default withPolicyConnections(CampfireCardProgramAccountPage); diff --git a/src/pages/workspace/accounting/campfire/export/CampfireCardProgramAccountSelectorPage.tsx b/src/pages/workspace/accounting/campfire/export/CampfireCardProgramAccountSelectorPage.tsx new file mode 100644 index 000000000000..ded0d93e8075 --- /dev/null +++ b/src/pages/workspace/accounting/campfire/export/CampfireCardProgramAccountSelectorPage.tsx @@ -0,0 +1,133 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; + +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsLists from '@hooks/useCardsLists'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useSelectionListSearch from '@hooks/useSelectionListSearch'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearCampfireErrorField, updateCampfireCardProgramAccount} from '@libs/actions/connections/Campfire'; +import {findMatchingCards} from '@libs/CardFeedUtils'; +import {getCustomOrFormattedFeedName} from '@libs/CardUtils'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import {sortDefaultToTop} from '@libs/ListUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {settingsPendingAction} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {CampfireAccount} from '@src/types/onyx/Policy'; + +import React from 'react'; +import {View} from 'react-native'; + +type AccountListItem = ListItem & { + value: CampfireAccount['id']; +}; + +type CampfireCardProgramAccountSelectorPageProps = WithPolicyConnectionsProps & + PlatformStackScreenProps; + +function CampfireCardProgramAccountSelectorPage({ + policy, + route: { + params: {feed: feedWithDomainID}, + }, +}: CampfireCardProgramAccountSelectorPageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + const policyID = policy?.id; + const [cardFeeds] = useCardFeeds(policyID); + const cardFeed = cardFeeds?.[feedWithDomainID]; + const [cardLists] = useCardsLists(); + const feedKey = cardFeed?.feed; + const campfireConfig = policy?.connections?.campfire?.config; + const campfireData = policy?.connections?.campfire?.data; + const creditCardAccountID = campfireConfig?.export?.creditCardAccountID; + const cardProgramsUsingCustomAccounts = campfireConfig?.export?.cardProgramAccounts; + const cardProgramAccountID = (feedKey ? cardProgramsUsingCustomAccounts?.[feedKey] : undefined) ?? creditCardAccountID; + const hasActiveCards = feedKey && findMatchingCards(cardFeeds ?? {}, cardLists, feedKey).length > 0; + const title = getCustomOrFormattedFeedName(translate, feedKey, cardFeed?.customFeedName, false); + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_CAMPFIRE_CARD_PROGRAM_ACCOUNT.getRoute(policyID) : undefined; + + const data: AccountListItem[] = + campfireData?.accounts + ?.filter( + (accountItem) => + accountItem.isActive && (accountItem.accountSubtype === CONST.CAMPFIRE_ACCOUNT_SUBTYPE.CREDIT_CARD || accountItem.accountSubtype === CONST.CAMPFIRE_ACCOUNT_SUBTYPE.BANK), + ) + .map((accountItem) => ({ + value: accountItem.id, + text: `${creditCardAccountID === accountItem.id ? `${translate('common.default')} - ` : ''}${accountItem.id} ${accountItem.name}`, + keyForList: accountItem.id, + isSelected: cardProgramAccountID === accountItem.id, + })) ?? []; + const {filteredData: filteredUnprocessedData, textInputOptions} = useSelectionListSearch(data); + const filteredData = sortDefaultToTop(filteredUnprocessedData, (accountItem) => creditCardAccountID === accountItem.keyForList, styles); + + const headerContent = ( + + {translate('workspace.campfire.cardProgramAccount.descriptionLevel2')} + + ); + + const listEmptyContent = ( + + ); + + const selectCreditCardAccount = (item: AccountListItem) => { + if (item.value !== cardProgramAccountID && policyID && feedKey) { + // Choosing the default account clears the custom account + const value = item.value === creditCardAccountID ? '' : item.value; + const oldValue = cardProgramAccountID === creditCardAccountID ? undefined : cardProgramAccountID; + updateCampfireCardProgramAccount(policyID, feedKey, value, oldValue); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE} + pendingAction={settingsPendingAction([`${CONST.CAMPFIRE_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], campfireConfig?.pendingFields)} + errors={getLatestErrorField(campfireConfig, `${CONST.CAMPFIRE_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearCampfireErrorField(policyID, `${CONST.CAMPFIRE_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`)} + /> + ); +} + +export default withPolicyConnections(CampfireCardProgramAccountSelectorPage); diff --git a/src/pages/workspace/accounting/campfire/export/CampfireExportPage.tsx b/src/pages/workspace/accounting/campfire/export/CampfireExportPage.tsx index c1ec2f43a958..08423428d65d 100644 --- a/src/pages/workspace/accounting/campfire/export/CampfireExportPage.tsx +++ b/src/pages/workspace/accounting/campfire/export/CampfireExportPage.tsx @@ -1,19 +1,30 @@ +import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; +import useAccordionAnimation from '@hooks/useAccordionAnimation'; +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsLists from '@hooks/useCardsLists'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {updateCampfireExportToMultipleAccounts, clearCampfireErrorField} from '@libs/actions/connections/Campfire'; +import {findMatchingCards, getCardsUsingCustomExportCount, getCardsCustomExportPendingAction, areCardsCustomExportInErrorFields} from '@libs/CardFeedUtils'; +import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import {View} from 'react-native'; @@ -21,6 +32,8 @@ function CampfireExportPage({policy}: WithPolicyConnectionsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id; + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); const policyOwner = policy?.owner; const campfireConfig = policy?.connections?.campfire?.config; const campfireData = policy?.connections?.campfire?.data; @@ -31,6 +44,16 @@ function CampfireExportPage({policy}: WithPolicyConnectionsProps) { const defaultCompanyCardVendor = campfireData?.vendors?.find((vendor) => vendor.id === campfireConfig?.export?.defaultVendorID); const companyCardAccountID = campfireConfig?.export?.creditCardAccountID; const companyCardAccount = campfireData?.accounts?.find((account) => account.id === companyCardAccountID); + const exportToMultipleAccounts = campfireConfig?.export?.exportToMultipleAccounts ?? false; + const cardProgramsUsingCustomAccountsCount = Object.keys(campfireConfig?.export?.cardProgramAccounts ?? {}).filter( + (cardFeed) => findMatchingCards(cardFeeds ?? {}, cardLists, cardFeed as CardFeedWithNumber).length > 0, + ).length; + const cardProgramsOfflineFeedbackKeys = Object.values(cardFeeds ?? {}).map((program) => `${CONST.CAMPFIRE_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${program.feed}`); + const cardsUsingCustomAccountsCount = getCardsUsingCustomExportCount(cardFeeds ?? {}, cardLists, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT); + const hasActiveCards = findMatchingCards(cardFeeds ?? {}, cardLists).length > 0; + + const {isAccordionExpanded: isExportToMultipleAccountsAccordionExpanded, shouldAnimateAccordionSection: shouldAnimateExportToMultipleAccountsAccordionSection} = + useAccordionAnimation(exportToMultipleAccounts); return ( + {hasActiveCards && ( + <> + policyID && updateCampfireExportToMultipleAccounts(policyID, !exportToMultipleAccounts, exportToMultipleAccounts)} + pendingAction={settingsPendingAction([CONST.CAMPFIRE_CONFIG.EXPORT_TO_MULTIPLE_ACCOUNTS], campfireConfig?.pendingFields)} + errors={getLatestErrorField(campfireConfig ?? {}, CONST.CAMPFIRE_CONFIG.EXPORT_TO_MULTIPLE_ACCOUNTS)} + onCloseError={() => policyID && clearCampfireErrorField(policyID, CONST.CAMPFIRE_CONFIG.EXPORT_TO_MULTIPLE_ACCOUNTS)} + /> + + + (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_CARD_PROGRAM_ACCOUNT.getRoute(policyID)) : undefined)} + value={translate('workspace.campfire.cardProgramAccount.countInfo', cardProgramsUsingCustomAccountsCount)} + > + {areSettingsInErrorFields(cardProgramsOfflineFeedbackKeys, campfireConfig?.errorFields) && ( + + )} + + + + (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_CARD_ACCOUNT.getRoute(policyID)) : undefined)} + value={translate('workspace.campfire.cardAccount.countInfo', cardsUsingCustomAccountsCount.totalCount)} + > + {areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT) && ( + + )} + + + + + )} ); } diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteTokenInputPage.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteTokenInputPage.tsx index e1ed41196b6d..8076c730aff8 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteTokenInputPage.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteTokenInputPage.tsx @@ -3,7 +3,6 @@ import InteractiveStepSubPageHeader from '@components/InteractiveStepSubPageHead import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import useSubPage from '@hooks/useSubPage'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -50,20 +49,20 @@ function NetSuiteTokenInputPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isProduction} = useEnvironment(); - const {isBetaEnabled} = usePermissions(); const {params} = useRoute(); const {authType} = params; - const canUseNetSuiteOAuth = isBetaEnabled(CONST.BETAS.NETSUITE_OAUTH); - // Only dev and staging can move a beta member back to token-based authentication. Production ignores the route param. - const canSwitchToTokenAuthentication = canUseNetSuiteOAuth && !isProduction; + const hasAuthError = isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); + // Only dev and staging can switch back to the token-based (TBA/SOAP) flow via route param for testing. + const canSwitchToTokenAuthentication = !isProduction; const isTokenAuthenticationSelected = canSwitchToTokenAuthentication && authType === CONST.NETSUITE_CONFIG.TOKEN_INPUT.AUTH_TYPE.TBA; - const isOAuthFlow = canUseNetSuiteOAuth && !isTokenAuthenticationSelected; + // TBA connections store a tokenID while OAuth connections do not, so this is used to pick the correct credentials + // form upon reconnection. Fresh connections will always use the OAuth wizard. + const netSuiteConnection = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]; + const isOAuthFlow = !(hasAuthError && !!netSuiteConnection?.tokenID) && !isTokenAuthenticationSelected; const pages = isOAuthFlow ? oauthPages : tokenPages; const stepNames = isOAuthFlow ? CONST.NETSUITE_CONFIG.TOKEN_INPUT.OAUTH_STEP_INDEX_LIST : CONST.NETSUITE_CONFIG.TOKEN_INPUT.STEP_INDEX_LIST; - const hasAuthError = isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); - const submit = () => { Navigation.dismissModal(); }; diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx index a26c558c960e..9c91f4f919f8 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx @@ -19,13 +19,8 @@ import {clearNetSuiteErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import { - areSettingsInErrorFields, - getFilteredApprovalAccountOptions, - getFilteredCollectionAccountOptions, - getFilteredReimbursableAccountOptions, - settingsPendingAction, -} from '@libs/PolicyUtils'; +import {getFilteredApprovalAccountOptions, getFilteredCollectionAccountOptions, getFilteredReimbursableAccountOptions} from '@libs/NetSuiteUtils'; +import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; import type {ExtendedMenuItemWithSubscribedSettings, MenuItemToRender} from '@pages/workspace/accounting/netsuite/types'; import { diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteApprovalAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteApprovalAccountSelectPage.tsx index 17df6a9bbff7..74c6745480bb 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteApprovalAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteApprovalAccountSelectPage.tsx @@ -10,7 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateNetSuiteApprovalAccount} from '@libs/actions/connections/NetSuiteCommands'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getNetSuiteApprovalAccountOptions, settingsPendingAction} from '@libs/PolicyUtils'; +import {getNetSuiteApprovalAccountOptions} from '@libs/NetSuiteUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; @@ -82,7 +83,7 @@ function NetSuiteApprovalAccountSelectPage({policy}: WithPolicyConnectionsProps) featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} displayName="NetSuiteApprovalAccountSelectPage" headerContent={headerContent} - isRowMultilineSupported + titleNumberOfLines={2} data={netsuiteApprovalAccountOptions} onSelectRow={updateCollectionAccount} initiallyFocusedOptionKey={initiallyFocusedOptionKey} diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCollectionAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCollectionAccountSelectPage.tsx index e9d9d7f547ee..29a77826d070 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCollectionAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCollectionAccountSelectPage.tsx @@ -10,7 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateNetSuiteCollectionAccount} from '@libs/actions/connections/NetSuiteCommands'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getNetSuiteCollectionAccountOptions, settingsPendingAction} from '@libs/PolicyUtils'; +import {getNetSuiteCollectionAccountOptions} from '@libs/NetSuiteUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteFxExpenseAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteFxExpenseAccountSelectPage.tsx index 058a8265781f..1d2dc915f116 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteFxExpenseAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteFxExpenseAccountSelectPage.tsx @@ -12,7 +12,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateNetSuiteFxExpenseAccount} from '@libs/actions/connections/NetSuiteCommands'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getNetSuiteExpenseAccountOptions, settingsPendingAction} from '@libs/PolicyUtils'; +import {getNetSuiteExpenseAccountOptions} from '@libs/NetSuiteUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import {shouldHideReimbursedReportsSection} from '@pages/workspace/accounting/netsuite/utils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteReimbursementAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteReimbursementAccountSelectPage.tsx index 16d0a73214b1..0c4a951cf9b1 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteReimbursementAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteReimbursementAccountSelectPage.tsx @@ -10,7 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateNetSuiteReimbursementAccountID} from '@libs/actions/connections/NetSuiteCommands'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getNetSuiteReimbursableAccountOptions, settingsPendingAction} from '@libs/PolicyUtils'; +import {getNetSuiteReimbursableAccountOptions} from '@libs/NetSuiteUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPage.tsx index 81fbfe4c08d4..8134b8ffdc06 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPage.tsx @@ -1,19 +1,19 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {clearNetSuiteErrorField} from '@libs/actions/Policy/Policy'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import Parser from '@libs/Parser'; import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; -import type {MenuItem} from '@pages/workspace/accounting/netsuite/types'; import { exportExpensesDestinationSettingName, shouldHideJournalPostingPreference, @@ -31,10 +31,6 @@ import type SCREENS from '@src/SCREENS'; import {useRoute} from '@react-navigation/native'; import React, {useMemo} from 'react'; -type MenuItemWithSubscribedSettings = Pick & { - subscribedSettings?: string[]; -}; - function DynamicNetSuiteExportExpensesPage({policy}: WithPolicyConnectionsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -58,76 +54,12 @@ function DynamicNetSuiteExportExpensesPage({policy}: WithPolicyConnectionsProps) const selectedReimbursablePayableAccount = useMemo(() => payableList?.find(({id}) => id === config?.reimbursablePayableAccount), [payableList, config?.reimbursablePayableAccount]); - const menuItems: MenuItemWithSubscribedSettings[] = [ - { - description: translate('workspace.accounting.exportAs'), - onPress: () => { - if (!policyID) { - return; - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_DESTINATION_SELECT.path)); - }, - title: exportDestination ? translate(`workspace.netsuite.exportDestination.values.${exportDestination}.label`) : undefined, - subscribedSettings: [exportDestinationSettingName], - onCloseError: () => clearNetSuiteErrorField(policyID, exportDestinationSettingName), - helperText: exportDestination ? translate(`workspace.netsuite.exportDestination.values.${exportDestination}.${helperTextType}`) : undefined, - shouldParseHelperText: true, - }, - { - description: translate('workspace.accounting.defaultVendor'), - onPress: () => { - if (!policyID) { - return; - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT.path)); - }, - title: defaultVendor ? defaultVendor.name : undefined, - subscribedSettings: [CONST.NETSUITE_CONFIG.DEFAULT_VENDOR], - onCloseError: () => clearNetSuiteErrorField(policyID, CONST.NETSUITE_CONFIG.DEFAULT_VENDOR), - shouldHide: shouldHideReimbursableDefaultVendor(isReimbursable, config), - }, - { - description: translate('workspace.netsuite.nonReimbursableJournalPostingAccount'), - onPress: () => { - if (!policyID) { - return; - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT.path)); - }, - title: selectedPayableAccount ? selectedPayableAccount.name : undefined, - subscribedSettings: [CONST.NETSUITE_CONFIG.PAYABLE_ACCT], - onCloseError: () => clearNetSuiteErrorField(policyID, CONST.NETSUITE_CONFIG.PAYABLE_ACCT), - shouldHide: shouldHideNonReimbursableJournalPostingAccount(isReimbursable, config), - }, - { - description: translate('workspace.netsuite.reimbursableJournalPostingAccount'), - onPress: () => { - if (!policyID) { - return; - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT.path)); - }, - title: selectedReimbursablePayableAccount ? selectedReimbursablePayableAccount.name : undefined, - subscribedSettings: [CONST.NETSUITE_CONFIG.REIMBURSABLE_PAYABLE_ACCOUNT], - onCloseError: () => clearNetSuiteErrorField(policyID, CONST.NETSUITE_CONFIG.REIMBURSABLE_PAYABLE_ACCOUNT), - shouldHide: shouldHideReimbursableJournalPostingAccount(isReimbursable, config), - }, - { - description: translate('workspace.netsuite.journalPostingPreference.label'), - onPress: () => { - if (!policyID) { - return; - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_JOURNAL_POSTING_PREFERENCE_SELECT.path)); - }, - title: config?.journalPostingPreference - ? translate(`workspace.netsuite.journalPostingPreference.values.${config.journalPostingPreference}`) - : translate(`workspace.netsuite.journalPostingPreference.values.${CONST.NETSUITE_JOURNAL_POSTING_PREFERENCE.JOURNALS_POSTING_INDIVIDUAL_LINE}`), - subscribedSettings: [CONST.NETSUITE_CONFIG.JOURNAL_POSTING_PREFERENCE], - onCloseError: () => clearNetSuiteErrorField(policyID, CONST.NETSUITE_CONFIG.JOURNAL_POSTING_PREFERENCE), - shouldHide: shouldHideJournalPostingPreference(isReimbursable, config), - }, - ]; + const navigateToStep = (path: Parameters[0]) => { + if (!policyID) { + return; + } + Navigation.navigate(createDynamicRoute(path)); + }; return ( - {menuItems - .filter((item) => !item.shouldHide) - .map((item) => ( - + navigateToStep(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_DESTINATION_SELECT.path)} + > + {areSettingsInErrorFields([exportDestinationSettingName], config?.errorFields) && } + + {!!exportDestination && ( + {Parser.replace(translate(`workspace.netsuite.exportDestination.values.${exportDestination}.${helperTextType}`))} + )} + + {!shouldHideReimbursableDefaultVendor(isReimbursable, config) && ( + + navigateToStep(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT.path)} + > + {areSettingsInErrorFields([CONST.NETSUITE_CONFIG.DEFAULT_VENDOR], config?.errorFields) && ( + + )} + + + )} + {!shouldHideNonReimbursableJournalPostingAccount(isReimbursable, config) && ( + + navigateToStep(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT.path)} + > + {areSettingsInErrorFields([CONST.NETSUITE_CONFIG.PAYABLE_ACCT], config?.errorFields) && ( + + )} + + + )} + {!shouldHideReimbursableJournalPostingAccount(isReimbursable, config) && ( + + navigateToStep(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT.path)} + > + {areSettingsInErrorFields([CONST.NETSUITE_CONFIG.REIMBURSABLE_PAYABLE_ACCOUNT], config?.errorFields) && ( + + )} + + + )} + {!shouldHideJournalPostingPreference(isReimbursable, config) && ( + + navigateToStep(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_JOURNAL_POSTING_PREFERENCE_SELECT.path)} > - - - ))} + {areSettingsInErrorFields([CONST.NETSUITE_CONFIG.JOURNAL_POSTING_PREFERENCE], config?.errorFields) && ( + + )} + + + )} ); } diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPayableAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPayableAccountSelectPage.tsx index 05e02c16b595..60f7d3c03634 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPayableAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPayableAccountSelectPage.tsx @@ -12,7 +12,8 @@ import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getNetSuitePayableAccountOptions, settingsPendingAction} from '@libs/PolicyUtils'; +import {getNetSuitePayableAccountOptions} from '@libs/NetSuiteUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx index 75298c35f884..d807fcb8f66d 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx @@ -12,7 +12,8 @@ import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getNetSuiteVendorOptions, settingsPendingAction} from '@libs/PolicyUtils'; +import {getNetSuiteVendorOptions} from '@libs/NetSuiteUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemSelectPage.tsx index f6a049db3840..c1b1a8545041 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemSelectPage.tsx @@ -10,7 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateNetSuiteInvoiceItem} from '@libs/actions/connections/NetSuiteCommands'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getNetSuiteInvoiceItemOptions, settingsPendingAction} from '@libs/PolicyUtils'; +import {getNetSuiteInvoiceItemOptions} from '@libs/NetSuiteUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteReceivableAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteReceivableAccountSelectPage.tsx index c9ae9c808a50..825b5d30afb5 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteReceivableAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteReceivableAccountSelectPage.tsx @@ -11,7 +11,8 @@ import {updateNetSuiteReceivableAccount} from '@libs/actions/connections/NetSuit import {clearNetSuiteErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getNetSuiteReceivableAccountOptions, settingsPendingAction} from '@libs/PolicyUtils'; +import {getNetSuiteReceivableAccountOptions} from '@libs/NetSuiteUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx index 7d4c31533afa..9b157228e59e 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx @@ -10,7 +10,8 @@ import {updateNetSuiteProvincialTaxPostingAccount} from '@libs/actions/connectio import {getLatestErrorField} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {canUseProvincialTaxNetSuite, getNetSuiteTaxAccountOptions, settingsPendingAction} from '@libs/PolicyUtils'; +import {canUseProvincialTaxNetSuite, getNetSuiteTaxAccountOptions} from '@libs/NetSuiteUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx index c29268769f98..ed76e9c47c4c 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx @@ -11,7 +11,8 @@ import {updateNetSuiteTaxPostingAccount} from '@libs/actions/connections/NetSuit import {getLatestErrorField} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {canUseTaxNetSuite, getNetSuiteTaxAccountOptions, settingsPendingAction} from '@libs/PolicyUtils'; +import {canUseTaxNetSuite, getNetSuiteTaxAccountOptions} from '@libs/NetSuiteUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx index 81d60874adca..9fb6dbc2f079 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx @@ -1,13 +1,13 @@ import ConnectionLayout from '@components/ConnectionLayout'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Parser from '@libs/Parser'; import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -90,13 +90,13 @@ function NetSuiteTravelBillingConfigurationPage({policy}: WithPolicyConnectionsP connectionName={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} onBackButtonPress={() => Navigation.goBack(netSuiteExportBackPath)} > - + + {Parser.replace(translate(`workspace.netsuite.exportDestination.values.${CONST.NETSUITE_EXPORT_DESTINATION.JOURNAL_ENTRY}.travelDescription`))} + {sections.map((section) => ( `${CONST.BUSINESS_CENTRAL_CONFIG.FIELD_MAPPING_PREFIX}${dimension.id}`) ?? []), ], - onExportPagePress: () => null, - subscribedExportSettings: [], + onExportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT.getRoute(policyID)), + subscribedExportSettings: [ + CONST.BUSINESS_CENTRAL_CONFIG.EXPORTER, + CONST.BUSINESS_CENTRAL_CONFIG.EXPORT_DATE, + CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSABLE, + CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSABLE_ACCOUNT, + CONST.BUSINESS_CENTRAL_CONFIG.NON_REIMBURSABLE, + CONST.BUSINESS_CENTRAL_CONFIG.DEFAULT_VENDOR_ID, + CONST.BUSINESS_CENTRAL_CONFIG.NON_REIMBURSABLE_ACCOUNT, + CONST.BUSINESS_CENTRAL_CONFIG.PAYMENT_METHOD_CODE, + ], onAdvancedPagePress: () => null, subscribedAdvancedSettings: [], workspaceUpgradeNavigationDetails: { diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx index f71a5dfe9e03..92940342da7c 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx @@ -5,7 +5,6 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; @@ -26,7 +25,6 @@ import React, {useMemo} from 'react'; function DynamicXeroExportConfigurationPage({policy}: WithPolicyConnectionsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isBetaEnabled} = usePermissions(); const policyID = policy?.id; const policyOwner = policy?.owner ?? ''; const dynamicBackPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path); @@ -41,7 +39,7 @@ function DynamicXeroExportConfigurationPage({policy}: WithPolicyConnectionsProps // row even when Xero is mid tenant-switch (config.isConfigured=false) and data.contacts is // stale from the prior tenant — allowing the admin to persist a defaultVendor that flips // invalid the moment the new sync completes. - const isVendorFeatureAvailable = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING) && isXeroVendorMatchingActive(policy); + const isVendorFeatureAvailable = isXeroVendorMatchingActive(policy); const defaultVendorName = getXeroSupplierByID(policy, defaultVendor)?.name ?? ''; const exportPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path}` : undefined; const workspaceAccountID = useWorkspaceAccountID(policyID); diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index 2213aa2eb5c8..bf9a55ee0bc4 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -5,7 +5,6 @@ import Text from '@components/Text'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateManyPolicyConnectionConfigs} from '@libs/actions/connections'; @@ -35,20 +34,19 @@ const CLEAR_DEFAULT_VENDOR_VALUE = ''; function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); - const {isBetaEnabled} = usePermissions(); const illustrations = useMemoizedLazyIllustrations(['Telescope']); const policyID = policy?.id; const xeroConfig = policy?.connections?.xero?.config; const currentContactID = xeroConfig?.defaultVendor; - // Match the parent page's gate so direct deep-links (or stale-open tabs after the beta is - // revoked) cannot reach the supplier updater. The parent page hides the row when the feature - // is off, but the route remains addressable on its own. Gated on Xero specifically being - // configured — not the global hasVendorFeature predicate — so dual-connected workspaces mid - // Xero tenant switch (config.isConfigured=false with stale data.contacts) cannot persist a - // defaultVendor from the prior tenant. - const isFeatureAvailable = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING) && isXeroVendorMatchingActive(policy); + // Match the parent page's gate so a direct deep link cannot reach the supplier updater. The + // parent page hides the row when the feature is off, but the route remains addressable on its + // own. The gate checks that Xero itself is configured instead of using the global + // hasVendorFeature predicate, so a workspace in the middle of a Xero tenant switch, where + // config.isConfigured is false and data.contacts still holds the previous tenant's contacts, + // cannot persist a defaultVendor from that tenant. + const isFeatureAvailable = isXeroVendorMatchingActive(policy); const suppliers = useMemo(() => getXeroSuppliers(policy), [policy]); const sortedSuppliers = sortVendors(suppliers, localeCompare); diff --git a/src/pages/workspace/categories/CategoryForm.tsx b/src/pages/workspace/categories/CategoryForm.tsx index 5e3753ef2a15..3742095facff 100644 --- a/src/pages/workspace/categories/CategoryForm.tsx +++ b/src/pages/workspace/categories/CategoryForm.tsx @@ -7,9 +7,8 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import {getCategoryNameError, getCategoryNameErrorMessage, getDecodedCategoryName} from '@libs/CategoryUtils'; import {addErrorMessage} from '@libs/ErrorUtils'; -import {isRequiredFulfilled} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -41,17 +40,10 @@ function CategoryForm({onSubmit, policyCategories, categoryName, validateEdit, a const validate = useCallback( (values: FormOnyxValues) => { const errors: FormInputErrors = {}; - const newCategoryName = values.categoryName.trim(); + const nameError = getCategoryNameError(policyCategories, values.categoryName); - if (!isRequiredFulfilled(newCategoryName)) { - errors.categoryName = translate('workspace.categories.categoryRequiredError'); - } else if (policyCategories?.[newCategoryName]) { - errors.categoryName = translate('workspace.categories.existingCategoryError'); - } else if (newCategoryName === CONST.INVALID_CATEGORY_NAME || newCategoryName === CONST.SEARCH.CATEGORY_DEFAULT_VALUE) { - errors.categoryName = translate('workspace.categories.invalidCategoryName'); - } else if ([...newCategoryName].length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) { - // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 code units. - addErrorMessage(errors, 'categoryName', translate('common.error.characterLimitExceedCounter', [...newCategoryName].length, CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH)); + if (nameError) { + addErrorMessage(errors, 'categoryName', getCategoryNameErrorMessage(translate, nameError, values.categoryName)); } return errors; diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index 6874738715d6..1c871d4be279 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -2,7 +2,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemFieldHTML from '@components/MenuItem/presets/MenuItemFieldHTML'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -33,6 +33,7 @@ import {getLatestErrorMessageField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {isDisablingOrDeletingLastEnabledCategory} from '@libs/OptionsListUtils'; +import Parser from '@libs/Parser'; import {arePolicyRulesEnabled, getWorkflowApprovalsUnavailable, hasTags, isControlPolicy, tryNavigateToControlPolicyUpgrade} from '@libs/PolicyUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -81,6 +82,7 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti ? [] : getCategoryContextualRules({ policy, + policyCategories, category: policyCategory, categoryName: policyCategory.name, translate, @@ -310,33 +312,35 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti {categoryRulesEnabled && ( <> - { - navigateToCategoryRule(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DESCRIPTION_HINT.path); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - shouldRenderAsHTML + { + navigateToCategoryRule(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DESCRIPTION_HINT.path); + } + : undefined + } /> - { - navigateToCategoryRule(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_APPROVER.path); - }} - interactive={canWriteCategories} - shouldShowRightIcon={canWriteCategories} - disabled={approverDisabled} - helperText={ - approverDisabled - ? translate('workspace.rules.categoryRules.enableWorkflows', `${environmentURL}/${ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)}`) + { + navigateToCategoryRule(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_APPROVER.path); + } : undefined } - shouldParseHelperText + isDisabled={approverDisabled} /> + {approverDisabled && ( + + {Parser.replace(translate('workspace.rules.categoryRules.enableWorkflows', `${environmentURL}/${ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)}`))} + + )} )} {canWriteCategories && !isThereAnyAccountingConnection && ( diff --git a/src/pages/workspace/categories/DynamicCreateCategoryPage.tsx b/src/pages/workspace/categories/DynamicCreateCategoryPage.tsx index 95ed3e78de12..c2a7151e38c0 100644 --- a/src/pages/workspace/categories/DynamicCreateCategoryPage.tsx +++ b/src/pages/workspace/categories/DynamicCreateCategoryPage.tsx @@ -13,6 +13,7 @@ import {createPolicyCategory} from '@libs/actions/Policy/Category'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {hasTags} from '@libs/PolicyUtils'; +import StringUtils from '@libs/StringUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -61,7 +62,7 @@ function DynamicCreateCategoryPage({route}: DynamicCreateCategoryPageProps) { (values: FormOnyxValues) => { createPolicyCategory({ policyID: route.params.policyID, - categoryName: values.categoryName.trim(), + categoryName: StringUtils.sanitizeName(values.categoryName), isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived, setupCategoryTaskReport, setupCategoryTaskParentReport, diff --git a/src/pages/workspace/categories/DynamicEditCategoryPage.tsx b/src/pages/workspace/categories/DynamicEditCategoryPage.tsx index 945537f20671..0953be8443cf 100644 --- a/src/pages/workspace/categories/DynamicEditCategoryPage.tsx +++ b/src/pages/workspace/categories/DynamicEditCategoryPage.tsx @@ -8,8 +8,10 @@ import usePermissions from '@hooks/usePermissions'; import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getCategoryNameError, getCategoryNameErrorMessage, getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import StringUtils from '@libs/StringUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -41,32 +43,26 @@ function DynamicEditCategoryPage({route}: DynamicEditCategoryPageProps) { const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_EDIT; const settingsBackPath = useDynamicBackPath(DYNAMIC_ROUTES.SETTINGS_CATEGORY_EDIT.path); const workspaceBackPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_EDIT.path); - - const sanitizeCategoryName = useCallback((name: string) => name.replaceAll(CONST.REGEX.NON_BREAKING_SPACE, ' ').trim(), []); + const decodedCategoryName = getDecodedCategoryName(currentCategoryName); const validate = useCallback( (values: FormOnyxValues) => { const errors: FormInputErrors = {}; - const newCategoryName = sanitizeCategoryName(values.categoryName); - - if (!newCategoryName) { - errors.categoryName = translate('workspace.categories.categoryRequiredError'); - } else if (policyCategories?.[newCategoryName] && currentCategoryName !== newCategoryName) { - errors.categoryName = translate('workspace.categories.existingCategoryError'); - } else if ([...newCategoryName].length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) { - // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 code units. - errors.categoryName = translate('common.error.characterLimitExceedCounter', [...newCategoryName].length, CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH); + const nameError = getCategoryNameError(policyCategories, values.categoryName, decodedCategoryName); + + if (nameError) { + errors.categoryName = getCategoryNameErrorMessage(translate, nameError, values.categoryName); } return errors; }, - [policyCategories, currentCategoryName, translate, sanitizeCategoryName], + [policyCategories, decodedCategoryName, translate], ); const editCategory = useCallback( (values: FormOnyxValues) => { - const newCategoryName = sanitizeCategoryName(values.categoryName); + const newCategoryName = StringUtils.sanitizeName(values.categoryName); // Do not call the API if the edited category name is the same as the current category name - if (currentCategoryName !== newCategoryName) { + if (decodedCategoryName !== newCategoryName) { renamePolicyCategory(policyData, {oldName: currentCategoryName, newName: newCategoryName}, isVendorMatchingBetaEnabled); } @@ -75,7 +71,7 @@ function DynamicEditCategoryPage({route}: DynamicEditCategoryPageProps) { Navigation.goBack(isQuickSettingsFlow ? settingsBackPath : workspaceBackPath, {compareParams: false}); }); }, - [currentCategoryName, policyData, isQuickSettingsFlow, settingsBackPath, workspaceBackPath, sanitizeCategoryName, isVendorMatchingBetaEnabled], + [decodedCategoryName, currentCategoryName, policyData, isQuickSettingsFlow, settingsBackPath, workspaceBackPath, isVendorMatchingBetaEnabled], ); return ( diff --git a/src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx index 07a42d753fe9..0772828a0ca5 100644 --- a/src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx +++ b/src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx @@ -4,18 +4,20 @@ import ScrollView from '@components/ScrollView'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; +import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {hasEnabledOptions} from '@libs/OptionsListUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; -import {setPolicyShowCategoryGLCodes} from '@userActions/Policy/Category'; +import {setPolicyAutoCategorizeNewExpenses, setPolicyShowCategoryGLCodes} from '@userActions/Policy/Category'; import {clearPolicyErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; @@ -34,22 +36,25 @@ function DynamicWorkspaceCategoriesSettingsPage({policy, route}: DynamicWorkspac const {policyID} = route.params; const styles = useThemeStyles(); const {translate} = useLocalize(); + const policyData = usePolicyData(policyID); const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORIES_SETTINGS; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.SETTINGS_CATEGORIES_SETTINGS.path); + const updateAutoCategorizeNewExpenses = (value: boolean) => { + setPolicyAutoCategorizeNewExpenses(policyID, value); + }; + const updateShowCategoryGLCodes = (value: boolean) => { setPolicyShowCategoryGLCodes(policyID, value); }; - // Only the GL codes toggle is left here, so the page has nothing to show without it. - const shouldBlockEmptySettings = !policy?.glCodes; + const hasEnabledCategories = hasEnabledOptions(policyData.categories); return ( Navigation.goBack(isQuickSettingsFlow ? backPath : undefined)} /> + clearPolicyErrorField(policy?.id, 'autoCategorizeNewExpenses')} + /> {!!policy?.glCodes && ( = 0) { - return num; - } - return undefined; -} - type ImportedCategoriesPageProps = { route: RouteProp; }; @@ -88,11 +62,21 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) { if (isControlPolicy(policy)) { roles.push( {text: translate('workspace.categories.glCode'), value: CONST.CSV_IMPORT_COLUMNS.GL_CODE}, + {text: translate('workspace.categories.payrollCode'), value: CONST.CSV_IMPORT_COLUMNS.PAYROLL_CODE}, {text: translate('workspace.rules.categoryRules.requireReceiptsOver'), value: CONST.CSV_IMPORT_COLUMNS.MAX_AMOUNT_NO_RECEIPT}, {text: translate('workspace.rules.categoryRules.requireItemizedReceiptsOver'), value: CONST.CSV_IMPORT_COLUMNS.MAX_AMOUNT_NO_ITEMIZED_RECEIPT}, ); } + if (arePolicyRulesEnabled(policy, policyCategories)) { + roles.push( + {text: translate('workspace.rules.categoryRules.requireDescription'), value: CONST.CSV_IMPORT_COLUMNS.ARE_COMMENTS_REQUIRED}, + {text: translate('workspace.rules.categoryRules.descriptionHint'), value: CONST.CSV_IMPORT_COLUMNS.COMMENT_HINT}, + {text: translate('workspace.rules.categoryRules.flagAmountsOver'), value: CONST.CSV_IMPORT_COLUMNS.MAX_EXPENSE_AMOUNT}, + {text: translate('workspace.rules.categoryRules.expenseLimitType'), value: CONST.CSV_IMPORT_COLUMNS.EXPENSE_LIMIT_TYPE}, + ); + } + return roles; }; @@ -139,35 +123,7 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) { return; } - const columns = Object.values(spreadsheet?.columns ?? {}); - const categoriesNamesColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.NAME); - const categoriesGLCodeColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.GL_CODE); - const categoriesEnabledColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.ENABLED); - const categoriesMaxAmountNoReceiptColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.MAX_AMOUNT_NO_RECEIPT); - const categoriesMaxAmountNoItemizedReceiptColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.MAX_AMOUNT_NO_ITEMIZED_RECEIPT); - const categoriesNames = spreadsheet?.data[categoriesNamesColumn].map((name) => name); - const categoriesEnabled = categoriesEnabledColumn !== -1 ? spreadsheet?.data[categoriesEnabledColumn].map((enabled) => enabled) : []; - const categoriesGLCode = categoriesGLCodeColumn !== -1 ? spreadsheet?.data[categoriesGLCodeColumn].map((glCode) => glCode) : []; - const categoriesMaxAmountNoReceipt = categoriesMaxAmountNoReceiptColumn !== -1 ? spreadsheet?.data[categoriesMaxAmountNoReceiptColumn] : []; - const categoriesMaxAmountNoItemizedReceipt = categoriesMaxAmountNoItemizedReceiptColumn !== -1 ? spreadsheet?.data[categoriesMaxAmountNoItemizedReceiptColumn] : []; - const categories = categoriesNames?.slice(containsHeader ? 1 : 0).map((name, index) => { - const categoryAlreadyExists = policyCategories?.[name]; - const existingGLCodeOrDefault = categoryAlreadyExists?.['GL Code'] ?? ''; - const dataIndex = containsHeader ? index + 1 : index; - - const parsedMaxAmountNoReceipt = categoriesMaxAmountNoReceiptColumn !== -1 ? parseCsvReceiptValue(categoriesMaxAmountNoReceipt?.[dataIndex]?.toString()) : undefined; - const parsedMaxAmountNoItemizedReceipt = - categoriesMaxAmountNoItemizedReceiptColumn !== -1 ? parseCsvReceiptValue(categoriesMaxAmountNoItemizedReceipt?.[dataIndex]?.toString()) : undefined; - - return { - name, - enabled: categoriesEnabledColumn !== -1 ? ['true', 'yes'].includes(categoriesEnabled?.[dataIndex]?.toString().trim().toLowerCase() ?? '') : true, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'GL Code': categoriesGLCodeColumn !== -1 ? (categoriesGLCode?.[dataIndex] ?? '') : existingGLCodeOrDefault, - ...(parsedMaxAmountNoReceipt !== undefined && {maxAmountNoReceipt: parsedMaxAmountNoReceipt}), - ...(parsedMaxAmountNoItemizedReceipt !== undefined && {maxAmountNoItemizedReceipt: parsedMaxAmountNoItemizedReceipt}), - }; - }); + const categories = buildCategoriesFromSpreadsheet(spreadsheet, policyCategories); if (categories) { setIsImportingCategories(true); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 40304f598e58..f70617af6aef 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -36,6 +36,7 @@ import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {isConnectionInProgress, isConnectionUnverified} from '@libs/actions/connections'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {renameCategoryInline} from '@libs/actions/Policy/InlineEdit'; import {getCategoryApproverRule, getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -267,6 +268,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const shouldShowGLCodeColumn = Object.values(policyCategories ?? {}).some((category) => !!category['GL Code']) && isControlPolicyWithWideLayout; const shouldShowApproverColumn = isControlPolicyWithWideLayout && arePolicyRulesEnabled(policy, policyCategories) && Object.keys(categoryApproverEmails).length > 0; + const isSelectionModeActive = selectedCategoryKeys.length > 0 || isMobileSelectionModeEnabled; + const categoryRows = useMemo(() => { return categories.reduce((acc, value) => { const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; @@ -292,8 +295,10 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { errors: value.errors ?? undefined, pendingAction: value.pendingAction, isLocked: isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value]) || !canWriteCategories || isDisabled, + canEditName: canWriteCategories && !isDisabled && !isSelectionModeActive, action: () => navigateToCategory(value), onToggleEnabled: (enabled: boolean) => handleCategoryToggle(enabled, value), + onRenameName: (newName: string) => renameCategoryInline(policyData, value.name, newName, isVendorMatchingBetaEnabled), dismissError: () => clearCategoryErrors(policyId, value.name, policyCategories), }); @@ -305,6 +310,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { shouldShowApproverColumn, categoryApproverEmails, canWriteCategories, + isSelectionModeActive, + policyData, policy, policyCategories, navigateToCategory, @@ -312,6 +319,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { policyId, employeePersonalDetails, formatPhoneNumber, + isVendorMatchingBetaEnabled, ]); const navigateToCategoriesSettings = useCallback(() => { @@ -370,7 +378,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const secondaryActions = useMemo(() => { const menuItems = []; // The other settings moved to Rules, so this is only worth showing for the GL codes toggle. - if (canWriteCategories && !!policy?.glCodes) { + if (canWriteCategories) { menuItems.push({ icon: icons.Gear, text: translate('common.settings'), @@ -418,7 +426,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { translate, canWriteCategories, navigateToCategoriesSettings, - policy?.glCodes, policyHasAccountingConnections, hasVisibleCategories, navigateToImportSpreadsheet, diff --git a/src/pages/workspace/companyCards/BankConnection/index.native.tsx b/src/pages/workspace/companyCards/BankConnection/index.native.tsx index c6c1a7505044..f0e0ea84804e 100644 --- a/src/pages/workspace/companyCards/BankConnection/index.native.tsx +++ b/src/pages/workspace/companyCards/BankConnection/index.native.tsx @@ -5,6 +5,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useCardFeeds from '@hooks/useCardFeeds'; +import useCompanyCardConnectionError from '@hooks/useCompanyCardConnectionError'; import useDuplicateFeedDetection from '@hooks/useDuplicateFeedDetection'; import useImportPlaidAccounts from '@hooks/useImportPlaidAccounts'; import useIsBlockedToAddFeed from '@hooks/useIsBlockedToAddFeed'; @@ -22,7 +23,7 @@ import Navigation from '@libs/Navigation/Navigation'; import WorkspaceCompanyCardsErrorConfirmation from '@pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation'; -import {setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; +import {clearAddNewCompanyCardErrors, setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; import {getCompanyCardBankConnection} from '@userActions/getCompanyCardBankConnection'; import CONST from '@src/CONST'; @@ -72,7 +73,7 @@ function BankConnection({policyID, feed, title}: BankConnectionProps) { const headerTitle = feed ? translate('workspace.companyCards.assignCard') : headerTitleAddCards; const onImportPlaidAccounts = useImportPlaidAccounts(policyID); const {updateBrokenConnection, isFeedConnectionBroken} = useUpdateFeedBrokenConnection({policyID, feed}); - const isNewFeedHasError = !!(newFeed && cardFeeds?.[newFeed]?.errors); + const {errorMessage, hasError: isNewFeedHasError} = useCompanyCardConnectionError({cardFeeds, addNewCard, newFeed, isAddingNewCard: !feed}); // importPlaidAccounts only writes these errors while repairing an existing feed, so the add-card flow ignores them const hasImportError = !!feed && !isEmptyObject(assignCard?.errors); const illustrations = useMemoizedLazyIllustrations(['BrokenCompanyCardBankConnection']); @@ -101,6 +102,7 @@ function BankConnection({policyID, feed, title}: BankConnectionProps) { return; } + clearAddNewCompanyCardErrors(); setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK}); }; @@ -230,6 +232,7 @@ function BankConnection({policyID, feed, title}: BankConnectionProps) { )} diff --git a/src/pages/workspace/companyCards/BankConnection/index.tsx b/src/pages/workspace/companyCards/BankConnection/index.tsx index a62f955dba43..a01e820cdaa1 100644 --- a/src/pages/workspace/companyCards/BankConnection/index.tsx +++ b/src/pages/workspace/companyCards/BankConnection/index.tsx @@ -8,6 +8,7 @@ import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useCardFeeds from '@hooks/useCardFeeds'; +import useCompanyCardConnectionError from '@hooks/useCompanyCardConnectionError'; import useDuplicateFeedDetection from '@hooks/useDuplicateFeedDetection'; import useImportPlaidAccounts from '@hooks/useImportPlaidAccounts'; import useIsBlockedToAddFeed from '@hooks/useIsBlockedToAddFeed'; @@ -25,7 +26,7 @@ import Navigation from '@libs/Navigation/Navigation'; import WorkspaceCompanyCardsErrorConfirmation from '@pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation'; import {updateSelectedFeed} from '@userActions/Card'; -import {setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; +import {clearAddNewCompanyCardErrors, setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; import {getCompanyCardBankConnection} from '@userActions/getCompanyCardBankConnection'; import CONST from '@src/CONST'; @@ -74,7 +75,7 @@ function BankConnection({policyID, feed, title}: BankConnectionProps) { const isFeedExpired = feed ? isSelectedFeedExpired(cardFeeds?.[feed]) : false; const headerTitleAddCards = translate('workspace.companyCards.addCards'); const headerTitle = feed ? translate('workspace.companyCards.assignCard') : headerTitleAddCards; - const isNewFeedHasError = !!(newFeed && cardFeeds?.[newFeed]?.errors); + const {errorMessage, hasError: isNewFeedHasError} = useCompanyCardConnectionError({cardFeeds, addNewCard, newFeed, isAddingNewCard: !feed}); // importPlaidAccounts only writes these errors while repairing an existing feed, so the add-card flow ignores them const hasImportError = !!feed && !isEmptyObject(assignCard?.errors); const onImportPlaidAccounts = useImportPlaidAccounts(policyID); @@ -106,6 +107,7 @@ function BankConnection({policyID, feed, title}: BankConnectionProps) { return; } + clearAddNewCompanyCardErrors(); setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK}); }; @@ -220,6 +222,7 @@ function BankConnection({policyID, feed, title}: BankConnectionProps) { ); } diff --git a/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardAccountSelectCardPage.tsx b/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardAccountSelectCardPage.tsx index 6511c1d2f55a..7997aa4871f0 100644 --- a/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardAccountSelectCardPage.tsx +++ b/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardAccountSelectCardPage.tsx @@ -7,6 +7,7 @@ import useCardFeeds from '@hooks/useCardFeeds'; import useCardsList from '@hooks/useCardsList'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useEnvironment from '@hooks/useEnvironment'; +import useInitialSelection from '@hooks/useInitialSelection'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; @@ -17,6 +18,7 @@ import {setCompanyCardExportAccount} from '@libs/actions/CompanyCards'; import {getCompanyCardFeed, getDomainOrWorkspaceAccountID, isExpensifyCard as isExpensifyCardUtil} from '@libs/CardUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getConnectedIntegration} from '@libs/PolicyUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import Navigation from '@navigation/Navigation'; @@ -71,7 +73,12 @@ function DynamicWorkspaceCompanyCardAccountSelectCardPage({route}: DynamicWorksp const featureName = isExpensifyCard ? CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED : CONST.POLICY.MORE_FEATURES.ARE_COMPANY_CARDS_ENABLED; const policyFeature = isExpensifyCard ? CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD : CONST.POLICY.POLICY_FEATURE.COMPANY_CARDS; - const searchedListOptions = tokenizedSearch(exportMenuItem?.data ?? [], searchText, (option) => [option.text ?? option.value]); + // Freeze the export account selected when the page opened so it stays pinned to the top of the list. + const selectedExportValue = exportMenuItem?.data?.find((option) => option.isSelected)?.value; + const initialExportValue = useInitialSelection(selectedExportValue, {resetOnFocus: true}); + // Pin the frozen initial account to the top of the full list before filtering, so it stays pinned while searching. + const orderedListOptions = moveInitialSelectionToTop(exportMenuItem?.data ?? [], initialExportValue !== undefined ? [String(initialExportValue)] : []); + const searchedListOptions = tokenizedSearch(orderedListOptions, searchText, (option) => [option.text ?? option.value]); const listEmptyContent = ( mode.isSelected)?.keyForList} + shouldUpdateFocusedIndex onBackButtonPress={() => Navigation.goBack(backPath)} headerTitleAlreadyTranslated={exportMenuItem?.description} listEmptyContent={listEmptyContent} connectionName={connectedIntegration} shouldShowTextInput={shouldShowTextInput} - isRowMultilineSupported + titleNumberOfLines={2} /> ); diff --git a/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx b/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx index 98d48fb561f6..5ccb97cfc706 100644 --- a/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx +++ b/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx @@ -255,12 +255,10 @@ function DynamicWorkspaceCompanyCardDetailsPage({route}: DynamicWorkspaceCompany {!!card?.nameValuePairs?.errorFields?.cardTitle && } - ) => { - updateCompanyCardName(domainOrWorkspaceAccountID, cardID, values[INPUT_IDS.NAME], getCompanyCardFeed(feed), defaultValue); + updateCompanyCardName(domainOrWorkspaceAccountID, cardID, StringUtils.sanitizeName(values[INPUT_IDS.NAME]), getCompanyCardFeed(feed), defaultValue); goBackToCardDetails(); }; const validate = (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [INPUT_IDS.NAME], translate); - if (values.name) { - const {isValid, byteLength} = isValidInputLength(values.name, CONST.STANDARD_LENGTH_LIMIT); - if (!isValid) { - addErrorMessage(errors, INPUT_IDS.NAME, translate('common.error.characterLimitExceedCounter', byteLength, CONST.STANDARD_LENGTH_LIMIT)); - } + const errors: FormInputErrors = {}; + const error = getCardNameError(values.name); + + if (error) { + errors[INPUT_IDS.NAME] = getCardNameErrorMessage(translate, error, values.name); } + return errors; }; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx index 798bd4c3376b..3dcc16911b5c 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx @@ -201,11 +201,9 @@ function WorkspaceCompanyCardFeedSelectorPage({route}: WorkspaceCompanyCardFeedS showTooltip={false} item={itemWithError} onSelectRow={selectOtherFeed} - isMultilineSupported - isAlternateTextMultilineSupported alternateTextNumberOfLines={2} titleNumberOfLines={2} - // BaseSelectListItem defaults to flex1 on the row; inside a column footer that makes rows split height and overlap. Size rows to content instead. + // SingleSelectListItem defaults to flex1 on the row; inside a column footer that makes rows split height and overlap. Size rows to content instead. wrapperStyle={[styles.flexReset, styles.w100]} /> ); diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation.tsx index 1e93e34f88fc..7cbf7e0a8633 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation.tsx @@ -13,7 +13,7 @@ import {getCompanyCardFeed, getCompanyFeeds, getDomainOrWorkspaceAccountID} from import Navigation from '@navigation/Navigation'; -import {deleteWorkspaceCompanyCardFeed, setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; +import {clearAddNewCompanyCardErrors, deleteWorkspaceCompanyCardFeed, setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; import {enableExpensifyCard} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; @@ -25,9 +25,10 @@ import React from 'react'; type WorkspaceCompanyCardsErrorConfirmationProps = { policyID?: string; newFeed?: CompanyCardFeedWithDomainID; + errorMessage?: string; }; -function WorkspaceCompanyCardsErrorConfirmation({policyID, newFeed}: WorkspaceCompanyCardsErrorConfirmationProps) { +function WorkspaceCompanyCardsErrorConfirmation({policyID, newFeed, errorMessage}: WorkspaceCompanyCardsErrorConfirmationProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(['BrokenCompanyCardBankConnection']); @@ -61,6 +62,7 @@ function WorkspaceCompanyCardsErrorConfirmation({policyID, newFeed}: WorkspaceCo if (!policyID) { return; } + clearAddNewCompanyCardErrors(); setAddNewCompanyCardStepAndData({ step: CONST.COMPANY_CARDS.STEP.PLAID_CONNECTION, data: { @@ -89,6 +91,7 @@ function WorkspaceCompanyCardsErrorConfirmation({policyID, newFeed}: WorkspaceCo heading={translate('workspace.moreFeatures.companyCards.bankConnectionError')} description={ + {!!errorMessage && `${errorMessage} `} {translate('workspace.moreFeatures.companyCards.bankConnectionDescription')}{' '} { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: false}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + conciergePersonalDetails, + shouldDismissModal: false, + }); }} > {' '} diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx index 50975ec2eeca..61dd90efe489 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx @@ -2,7 +2,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -154,15 +153,13 @@ function WorkspaceCompanyCardsSettingsPage({ onPress={navigateToChangeFeedName} /> - + > + {!!selectedFeedData?.errorFields?.statementPeriodEndDay && } + ) => { setAssignCardStepAndData({ cardToAssign: { - customCardName: values.name, + customCardName: StringUtils.sanitizeName(values.name), isCustomCardNameEdited: true, }, isEditing: false, @@ -53,13 +53,13 @@ function CardNameStep({route}: CardNameStepProps) { }; const validate = (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [INPUT_IDS.NAME], translate); - if (values.name) { - const {isValid, byteLength} = isValidInputLength(values.name, CONST.STANDARD_LENGTH_LIMIT); - if (!isValid) { - addErrorMessage(errors, INPUT_IDS.NAME, translate('common.error.characterLimitExceedCounter', byteLength, CONST.STANDARD_LENGTH_LIMIT)); - } + const errors: FormInputErrors = {}; + const error = getCardNameError(values.name); + + if (error) { + errors[INPUT_IDS.NAME] = getCardNameErrorMessage(translate, error, values.name); } + return errors; }; diff --git a/src/pages/workspace/companyCards/utils.tsx b/src/pages/workspace/companyCards/utils.tsx index 74fcc198b211..8addbeed7074 100644 --- a/src/pages/workspace/companyCards/utils.tsx +++ b/src/pages/workspace/companyCards/utils.tsx @@ -84,7 +84,10 @@ type ProgramAccountExport = { type: typeof CONST.COMPANY_CARDS.EXPORT_RESOLVER.PROGRAM_ACCOUNT; /** The card NVP holding this integration's export account */ - nvpKey: typeof CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT | typeof CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT; + nvpKey: + | typeof CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT + | typeof CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT + | typeof CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT; /** Every account, in connection order, because an account that is no longer offered can still be the resolved one */ accounts: ProgramAccountOption[]; @@ -502,6 +505,42 @@ function getPolicyCardExportSettings( }, }; } + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: { + const campfireConfig = policy?.connections?.campfire?.config; + const campfireData = policy?.connections?.campfire?.data; + const exportReimbursable = campfireConfig?.export?.reimbursable ?? CONST.CAMPFIRE_EXPORT_REIMBURSABLE.VENDOR_BILL; + const exportNonReimbursable = campfireConfig?.export?.nonReimbursable ?? CONST.CAMPFIRE_EXPORT_NON_REIMBURSABLE.JOURNAL_ENTRY; + + return { + description: currentConnectionName + ? translate('workspace.moreFeatures.companyCards.integrationExport', currentConnectionName, translate('workspace.campfire.cardAccount.label')) + : undefined, + exportType: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT, + shouldHideMenuItemDescription: true, + shouldShowMenuItemIcon: true, + shouldShowMenuItem: + campfireConfig?.export?.exportToMultipleAccounts && + exportReimbursable === CONST.CAMPFIRE_EXPORT_REIMBURSABLE.VENDOR_BILL && + exportNonReimbursable === CONST.CAMPFIRE_EXPORT_NON_REIMBURSABLE.JOURNAL_ENTRY, + accountSelection: { + type: CONST.COMPANY_CARDS.EXPORT_RESOLVER.PROGRAM_ACCOUNT, + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_CAMPFIRE_EXPORT_ACCOUNT, + accounts: (campfireData?.accounts ?? []).map((account) => ({ + id: account.id, + label: `${account.id} ${account.name}`, + keyForList: account.id, + configKey: account.id, + isSelectable: + account.isActive && (account.accountSubtype === CONST.CAMPFIRE_ACCOUNT_SUBTYPE.CREDIT_CARD || account.accountSubtype === CONST.CAMPFIRE_ACCOUNT_SUBTYPE.BANK), + })), + workspaceProgramAccountKey: campfireConfig?.export?.creditCardAccountID, + programAccountKeysByFeed: campfireConfig?.export?.cardProgramAccounts, + exportsToLabel: translate('common.exportsTo'), + defaultTitleSuffix: translate('common.default').toLocaleLowerCase(), + defaultOptionPrefix: translate('common.default'), + }, + }; + } default: return undefined; } diff --git a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx index 7bf9dce5016a..d80b77989913 100644 --- a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx +++ b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx @@ -42,6 +42,7 @@ function CopyPolicySettingsConfirmPage() { const [copyPolicySettingsState, copyPolicySettingsMetadata] = useOnyx(ONYXKEYS.COPY_POLICY_SETTINGS); const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const [allRules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const sourcePolicy = sourcePolicyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${sourcePolicyID}`] : undefined; const targetPolicyIDs = copyPolicySettingsState?.targetPolicyIDs ?? []; @@ -91,7 +92,7 @@ function CopyPolicySettingsConfirmPage() { Navigation.navigate(ROUTES.POLICY_COPY_SETTINGS_UPGRADE.getRoute(sourcePolicyID)); return; } - copyPolicySettings(sourcePolicy, targetPolicies, parts, allPolicyCategories, allPolicyTags); + copyPolicySettings(sourcePolicy, targetPolicies, parts, allPolicyCategories, allPolicyTags, allRules); Navigation.dismissModal(); }; diff --git a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsProgressModal.tsx b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsProgressModal.tsx index 74e0a79bd4ba..627f636a9eb0 100644 --- a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsProgressModal.tsx +++ b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsProgressModal.tsx @@ -4,6 +4,7 @@ import RenderHTML from '@components/RenderHTML'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearCopyPolicySettings, requestCopyPolicySettingsNotification, setCopyPolicySettingsData} from '@libs/actions/Policy/CopyPolicySettings'; @@ -26,6 +27,7 @@ function useCopyPolicySettingsProgressModal() { const [bulkPolicyCopySettings] = useOnyx(ONYXKEYS.NVP_BULK_POLICY_COPY_SETTINGS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const copyInProgressStep = copyPolicySettings?.currentStep === CONST.POLICY.COPY_SETTINGS_MODAL_STEP.LOADING; @@ -122,7 +124,7 @@ function useCopyPolicySettingsProgressModal() { shouldShowCancelButton: true, onConfirm: () => { clearCopyPolicySettings(); - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: false}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); }, onCancel: () => { clearCopyPolicySettings(); diff --git a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx index 43923a0123a9..957a98d2fad6 100644 --- a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx +++ b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx @@ -13,6 +13,8 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; +import useRulesPrefetch from '@hooks/useRulesPrefetch'; import useThemeStyles from '@hooks/useThemeStyles'; import {setCopyPolicySettingsData} from '@libs/actions/Policy/CopyPolicySettings'; @@ -54,6 +56,7 @@ import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useRoute} from '@react-navigation/native'; +import {createExpenseDefaultRuleCountSelector} from '@selectors/Rule'; import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; @@ -81,6 +84,8 @@ function CopyPolicySettingsSelectFeaturesPage() { const [copyPolicySettings] = useOnyx(ONYXKEYS.COPY_POLICY_SETTINGS); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${sourcePolicyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${sourcePolicyID}`); + const [codingRulesCount = 0] = useOnyx(ONYXKEYS.COLLECTION.RULE, {selector: createExpenseDefaultRuleCountSelector(sourcePolicyID)}); + useRulesPrefetch(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const sourcePolicy = sourcePolicyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${sourcePolicyID}`] : undefined; @@ -98,9 +103,9 @@ function CopyPolicySettingsSelectFeaturesPage() { const hasTargetWithoutAddress = targetPolicies.some((policy) => isEmptyObject(policy?.address)); const sourceEmployeePersonalDetails = usePersonalDetailsByLogins(Object.keys(sourcePolicy?.employeeList ?? {})); - const [memberCount = 0] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - selector: createFilteredMemberCountSelector(sourcePolicy?.employeeList, sourcePolicy?.owner, currentUserPersonalDetails.login, sourceEmployeePersonalDetails), - }); + const [memberCount = 0] = useAllPersonalDetails( + createFilteredMemberCountSelector(sourcePolicy?.employeeList, sourcePolicy?.owner, currentUserPersonalDetails.login, sourceEmployeePersonalDetails), + ); const invoiceCompany = [sourcePolicy?.invoice?.companyName, sourcePolicy?.invoice?.companyWebsite].filter(Boolean).join(', '); const [invoiceConfigurationText = ''] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, { selector: createInvoiceConfigurationTextSelector(translate, invoiceCompany), @@ -116,7 +121,6 @@ function CopyPolicySettingsSelectFeaturesPage() { const policyFields = Object.values(getReportFieldsByPolicyID(sourcePolicy) ?? {}).filter((field) => field.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const reportFieldsCount = policyFields.filter((field) => field.target !== CONST.REPORT_FIELD_TARGETS.INVOICE).length; const invoiceFieldsCount = policyFields.filter((field) => field.target === CONST.REPORT_FIELD_TARGETS.INVOICE).length; - const codingRulesCount = Object.values(sourcePolicy?.rules?.codingRules ?? {}).filter((rule) => rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; const connectedIntegration = getAllValidConnectedIntegration(sourcePolicy, CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES); const distanceRatesCount = Object.values(getDistanceRateCustomUnit(sourcePolicy)?.rates ?? {}).filter((rate) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; const perDiemRates = getPerDiemCustomUnit(sourcePolicy)?.rates ?? {}; diff --git a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsUpgradePage.tsx b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsUpgradePage.tsx index edd02bc0de1d..fb4677c9f163 100644 --- a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsUpgradePage.tsx +++ b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsUpgradePage.tsx @@ -86,7 +86,8 @@ function CopyPolicySettingsUpgradePage() { if (!sourcePolicyID || !isDataLoaded || hasRequestedUpgrade || policiesToUpgrade.length > 0) { return; } - Navigation.navigate(ROUTES.POLICY_COPY_SETTINGS_CONFIRM.getRoute(sourcePolicyID)); + // This step is no longer valid, so replace it instead of leaving it beneath Confirm in the navigation stack. + Navigation.navigate(ROUTES.POLICY_COPY_SETTINGS_CONFIRM.getRoute(sourcePolicyID), {forceReplace: true}); }, [isDataLoaded, hasRequestedUpgrade, policiesToUpgrade.length, sourcePolicyID]); const onUpgrade = () => { @@ -103,7 +104,8 @@ function CopyPolicySettingsUpgradePage() { if (!sourcePolicyID) { return; } - Navigation.navigate(ROUTES.POLICY_COPY_SETTINGS_CONFIRM.getRoute(sourcePolicyID)); + // Upgrade is a one-way step, so replace it to keep a refreshed stack from returning to the stale success view. + Navigation.navigate(ROUTES.POLICY_COPY_SETTINGS_CONFIRM.getRoute(sourcePolicyID), {forceReplace: true}); }; return ( diff --git a/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx index f737d34247a8..03717d85e013 100644 --- a/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx +++ b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx @@ -8,6 +8,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOutstandingBalanceGuard from '@hooks/useOutstandingBalanceGuard'; import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePrevious from '@hooks/usePrevious'; import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -91,7 +92,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork }); const [travelCardSettings, travelCardSettingsResult] = useOnyx(getTravelBillingCardSettingsKey(workspaceAccountID)); const {reportsToArchive, transactionViolations, reportsResult, transactionsResult, transactionViolationsResult} = useTransactionViolationOfWorkspace(policyID); - const [accountIDToLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: accountIDToLoginSelector(reportsToArchive)}); + const [accountIDToLogin] = useAllPersonalDetails(accountIDToLoginSelector(reportsToArchive)); const isLoadingData = isLoadingOnyxValue( policiesResult, diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateNameEditPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateNameEditPage.tsx index 5e18d55051d8..b30b7ff4d0d4 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateNameEditPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateNameEditPage.tsx @@ -12,7 +12,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {getDistanceRateNameError, getDistanceRateNameErrorMessage} from '@libs/PolicyDistanceRatesUtils'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; +import StringUtils from '@libs/StringUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -46,15 +48,11 @@ function PolicyDistanceRateNameEditPage({route}: PolicyDistanceRateNameEditPageP const validate = useCallback( (values: FormOnyxValues) => { const errors: FormInputErrors = {}; - const newRateName = values.rateName.trim(); - - if (!newRateName) { - errors.rateName = translate('workspace.distanceRates.errors.rateNameRequired'); - } else if (Object.values(customUnit?.rates ?? {}).some((r) => r.name === newRateName) && currentRateName !== newRateName) { - errors.rateName = translate('workspace.distanceRates.errors.existingRateName'); - } else if ([...newRateName].length > CONST.TAX_RATES.NAME_MAX_LENGTH) { - // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 code units. - errors.rateName = translate('common.error.characterLimitExceedCounter', [...newRateName].length, CONST.TAX_RATES.NAME_MAX_LENGTH); + const existingRateNames = Object.values(customUnit?.rates ?? {}).map((existingRate) => existingRate.name ?? ''); + const nameError = getDistanceRateNameError(existingRateNames, values.rateName, currentRateName); + + if (nameError) { + errors.rateName = getDistanceRateNameErrorMessage(translate, nameError, values.rateName); } return errors; @@ -67,11 +65,12 @@ function PolicyDistanceRateNameEditPage({route}: PolicyDistanceRateNameEditPageP if (!customUnit || !rate) { return; } - if (currentRateName === values.rateName) { + const sanitized = StringUtils.sanitizeName(values.rateName); + if (currentRateName === sanitized) { Navigation.goBack(); return; } - updatePolicyDistanceRateName(policyID, customUnit, [{...rate, name: values.rateName}]); + updatePolicyDistanceRateName(policyID, customUnit, [{...rate, name: sanitized}]); Keyboard.dismiss(); Navigation.goBack(); }, diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index de8c2c657abd..b9995ac400b4 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -33,6 +33,7 @@ import { openPolicyDistanceRatesPage, setPolicyDistanceRatesEnabled, } from '@libs/actions/Policy/DistanceRate'; +import {renameDistanceRateInline, updateDistanceRateValueInline} from '@libs/actions/Policy/InlineEdit'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -65,7 +66,7 @@ function PolicyDistanceRatesPage({ const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close', 'Gear', 'Plus', 'Trashcan']); const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {translate, toLocaleDigit} = useLocalize(); const {showConfirmModal} = useConfirmModal(); const policy = usePolicy(policyID); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.distanceRates'); @@ -153,6 +154,8 @@ function PolicyDistanceRatesPage({ useCleanupSelectedOptions(clearTableSelection); + const isSelectionModeActive = selectedDistanceRates.length > 0 || isMobileSelectionModeEnabled; + const canDisableOrDeleteSelectedRates = useMemo( () => Object.keys(selectableRates) @@ -315,18 +318,35 @@ function PolicyDistanceRatesPage({ formattedRate: `${convertAmountToDisplayString(rate.rate, rate.currency ?? CONST.CURRENCY.USD)} / ${unitTranslation}`, pendingAction: resolvedPendingAction ?? undefined, errors: rate.errors ?? undefined, + canEditName: canWriteDistanceRates && !isDeleting && !isSelectionModeActive, + canEditRate: canWriteDistanceRates && !isDeleting && !isSelectionModeActive, action: () => openRateDetailsByID(rate.customUnitRateID), dismissError: () => dismissErrorByID(rate.customUnitRateID), onToggleEnabled: (value: boolean) => updateDistanceRateEnabled(value, rate.customUnitRateID), + onRenameName: (newName: string) => { + if (!customUnit) { + return; + } + renameDistanceRateInline(policyID, customUnit, rate, newName); + }, + onChangeRate: (newRate: string) => { + if (!customUnit) { + return; + } + updateDistanceRateValueInline(policyID, customUnit, rate, newRate, toLocaleDigit); + }, }; }), [ customUnitRates, unitTranslation, - customUnit?.pendingFields?.attributes, + customUnit, policy?.pendingAction, canWriteDistanceRates, canDisableOrDeleteRate, + isSelectionModeActive, + policyID, + toLocaleDigit, openRateDetailsByID, dismissErrorByID, updateDistanceRateEnabled, diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx index 99390334fe1f..4b49d7560645 100644 --- a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx @@ -12,9 +12,12 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; +import useRulesPrefetch from '@hooks/useRulesPrefetch'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getExpenseDefaultRuleCount} from '@libs/ExpenseDefaultRuleUtils'; import {readFileAsync} from '@libs/fileDownload/FileUtils'; import {createFilteredMemberCountSelector, createInvoiceConfigurationTextSelector, getDistanceRateCustomUnit, getPerDiemCustomUnit, isCollectPolicy} from '@libs/PolicyUtils'; import {formatAddressToString} from '@libs/ReportActionsUtils'; @@ -46,12 +49,14 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const isCollect = isCollectPolicy(policy); const {showConfirmModal} = useConfirmModal(); const [duplicateWorkspace] = useOnyx(ONYXKEYS.DUPLICATE_WORKSPACE); + const [allRules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + useRulesPrefetch(); const [duplicatedWorkspaceAvatar, setDuplicatedWorkspaceAvatar] = useState(); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const taxesLength = Object.values(policy?.taxRates?.taxes ?? {}).filter((tax) => tax.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length ?? 0; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const categoriesCount = Object.values(policyCategories ?? {}).filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; - const codingRulesCount = Object.values(policy?.rules?.codingRules ?? {}).filter((rule) => rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; + const codingRulesCount = getExpenseDefaultRuleCount(allRules, policy?.id); const [selectedItems, setSelectedItems] = useState([]); const policyFields = Object.values(getReportFieldsByPolicyID(policy) ?? {}).filter((field) => field.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const reportFields = policyFields.filter((field) => field.target !== CONST.REPORT_FIELD_TARGETS.INVOICE).length; @@ -62,9 +67,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); const totalMembersSelector = createFilteredMemberCountSelector(policy?.employeeList, policy?.owner, currentUserPersonalDetails.login, employeePersonalDetails); - const [totalMembers = 0] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - selector: totalMembersSelector, - }); + const [totalMembers = 0] = useAllPersonalDetails(totalMembersSelector); // The invoicing company details are provisioned per workspace, so they aren't copied over to the duplicate and shouldn't be advertised here. const invoiceConfigurationTextSelector = createInvoiceConfigurationTextSelector(translate, ''); const [invoiceConfigurationText = ''] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, { @@ -246,6 +249,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm }, file: duplicatedWorkspaceAvatar, localCurrency: currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD, + rules: allRules, }); Navigation.closeRHPFlow(); }; diff --git a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx index ae57d6181e41..9285c419db19 100644 --- a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx @@ -17,7 +17,13 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateExpensifyCardLimit} from '@libs/actions/Card'; -import {filterInactiveCardsForWorkspace} from '@libs/CardUtils'; +import { + filterInactiveCardsForWorkspace, + getExpensifyCardLimitChangeWarningKey, + getExpensifyCardLimitError, + getExpensifyCardLimitErrorMessage, + getExpensifyCardNewAvailableSpend, +} from '@libs/CardUtils'; import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; @@ -33,9 +39,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/EditExpensifyCardLimitForm'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; - -type ConfirmationWarningTranslationPaths = 'workspace.expensifyCard.smartLimitWarning' | 'workspace.expensifyCard.monthlyLimitWarning' | 'workspace.expensifyCard.fixedLimitWarning'; +import React, {useCallback, useEffect, useRef} from 'react'; type DynamicExpensifyCardLimitPageProps = PlatformStackScreenProps; @@ -61,26 +65,9 @@ function DynamicExpensifyCardLimitPage({route}: DynamicExpensifyCardLimitPagePro cardRef.current = card; }, [card]); - const getPromptTextKey = useMemo((): ConfirmationWarningTranslationPaths => { - switch (card?.nameValuePairs?.limitType) { - case CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART: - return 'workspace.expensifyCard.smartLimitWarning'; - case CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED: - return 'workspace.expensifyCard.fixedLimitWarning'; - case CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY: - return 'workspace.expensifyCard.monthlyLimitWarning'; - default: - return 'workspace.expensifyCard.fixedLimitWarning'; - } - }, [card?.nameValuePairs?.limitType]); - - const getNewAvailableSpend = (newLimit: number) => { - const latestCard = cardRef.current; - const currentLimit = latestCard?.nameValuePairs?.unapprovedExpenseLimit ?? 0; - const currentSpend = currentLimit - (latestCard?.availableSpend ?? 0); + const getPromptTextKey = () => getExpensifyCardLimitChangeWarningKey(cardRef.current?.nameValuePairs?.limitType); - return newLimit - currentSpend; - }; + const getNewAvailableSpend = (newLimit: number) => getExpensifyCardNewAvailableSpend(cardRef.current, newLimit); const goBack = useCallback(() => { Navigation.goBack(backPath, {compareParams: false}); @@ -110,7 +97,7 @@ function DynamicExpensifyCardLimitPage({route}: DynamicExpensifyCardLimitPagePro if (newAvailableSpend <= 0) { showConfirmModal({ title: translate('workspace.expensifyCard.changeCardLimit'), - prompt: translate(getPromptTextKey, convertToDisplayString(newLimit, currency)), + prompt: translate(getPromptTextKey(), convertToDisplayString(newLimit, currency)), confirmText: translate('workspace.expensifyCard.changeLimit'), cancelText: translate('common.cancel'), buttonVariant: CONST.BUTTON_VARIANT.DANGER, @@ -130,15 +117,13 @@ function DynamicExpensifyCardLimitPage({route}: DynamicExpensifyCardLimitPagePro const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors = getFieldRequiredErrors(values, [INPUT_IDS.LIMIT], translate); - - if (Number.isNaN(Number(values.limit))) { - errors.limit = translate('iou.error.invalidAmount'); - } else if (!Number.isInteger(Number(values.limit))) { - errors.limit = translate('iou.error.invalidIntegerAmount'); + if (errors.limit) { + return errors; } - if (Number(values.limit) > CONST.EXPENSIFY_CARD.LIMIT_VALUE) { - errors.limit = translate('workspace.card.issueNewCard.cardLimitError'); + const limitError = getExpensifyCardLimitError(values.limit); + if (limitError) { + errors.limit = getExpensifyCardLimitErrorMessage(translate, limitError); } return errors; }, diff --git a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx index b2816360222c..417fe92dba66 100644 --- a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx @@ -27,7 +27,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateExpensifyCardLimitType} from '@libs/actions/Card'; import {openPolicyEditCardLimitTypePage} from '@libs/actions/Policy/Policy'; -import {filterInactiveCardsForWorkspace, getDefaultExpensifyCardLimitType} from '@libs/CardUtils'; +import { + filterInactiveCardsForWorkspace, + getDefaultExpensifyCardLimitType, + getExpensifyCardLimitTypeChangeWarningKey, + getVisibleExpensifyCardLimitTypes, + shouldConfirmExpensifyCardLimitTypeChange, +} from '@libs/CardUtils'; import DateUtils from '@libs/DateUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {canMemberRead, getApprovalWorkflow} from '@libs/PolicyUtils'; @@ -77,10 +83,6 @@ function DynamicExpensifyCardLimitTypePage({route}: WorkspaceEditCardLimitTypePa const areApprovalsConfigured = getApprovalWorkflow(policy) !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; const defaultLimitType = getDefaultExpensifyCardLimitType(policy); const initialLimitType = card?.nameValuePairs?.limitType ?? defaultLimitType; - const promptTranslationKey = - initialLimitType === CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY || initialLimitType === CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED - ? 'workspace.expensifyCard.changeCardSmartLimitTypeWarning' - : 'workspace.expensifyCard.changeCardMonthlyLimitTypeWarning'; const [typeSelected, setTypeSelected] = useState(initialLimitType); const [expirationToggle, setExpirationToggle] = useState(!!card?.nameValuePairs?.validFrom); @@ -107,44 +109,24 @@ function DynamicExpensifyCardLimitTypePage({route}: WorkspaceEditCardLimitTypePa useFocusEffect(fetchCardLimitTypeData); const updateCardLimitType = (values: FormOnyxValues) => { - updateExpensifyCardLimitType( - defaultFundID, - Number(cardID), - typeSelected, - assigneeTimeZone, - latestCardRef.current?.nameValuePairs, - values[INPUT_IDS.VALID_FROM], - values[INPUT_IDS.VALID_THRU], - !expirationToggle, - ); + updateExpensifyCardLimitType({ + workspaceAccountID: defaultFundID, + cardID: Number(cardID), + newLimitType: typeSelected, + timeZone: assigneeTimeZone, + oldCardNameValuePairs: latestCardRef.current?.nameValuePairs, + validFrom: values[INPUT_IDS.VALID_FROM], + validThru: values[INPUT_IDS.VALID_THRU], + shouldClearValidityDates: !expirationToggle, + }); goBack(); }; const submit = (values: FormOnyxValues) => { - let shouldShowConfirmModal = false; - if (!!card?.unapprovedSpend && card?.nameValuePairs?.unapprovedExpenseLimit) { - // Spends are coming as negative numbers from the backend and we need to make it positive for the correct expression. - const unapprovedSpend = Math.abs(card.unapprovedSpend); - const isUnapprovedSpendOverLimit = unapprovedSpend >= card.nameValuePairs.unapprovedExpenseLimit; - - const validCombinations = [ - [CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY, CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART], - [CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART, CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY], - [CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED, CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART], - [CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED, CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY], - ]; - // Check if the combination exists in validCombinations - const isValidCombination = validCombinations.some(([limitType, selectedType]) => initialLimitType === limitType && typeSelected === selectedType); - - if (isValidCombination && isUnapprovedSpendOverLimit) { - shouldShowConfirmModal = true; - } - } - - if (shouldShowConfirmModal) { + if (shouldConfirmExpensifyCardLimitTypeChange(card, typeSelected, defaultLimitType)) { showConfirmModal({ title: translate('workspace.expensifyCard.changeCardLimitType'), - prompt: translate(promptTranslationKey, convertToDisplayString(card?.nameValuePairs?.unapprovedExpenseLimit, currency)), + prompt: translate(getExpensifyCardLimitTypeChangeWarningKey(initialLimitType), convertToDisplayString(card?.nameValuePairs?.unapprovedExpenseLimit, currency)), confirmText: translate('workspace.expensifyCard.changeLimitType'), cancelText: translate('common.cancel'), buttonVariant: CONST.BUTTON_VARIANT.DANGER, @@ -155,22 +137,13 @@ function DynamicExpensifyCardLimitTypePage({route}: WorkspaceEditCardLimitTypePa } updateCardLimitType(values); }); - } else { - updateCardLimitType(values); + return; } - }; - let shouldShowFixedOption = true; + updateCardLimitType(values); + }; - if (card?.totalSpend && card?.nameValuePairs?.unapprovedExpenseLimit) { - const totalSpend = Math.abs(card.totalSpend); - if ( - (initialLimitType === CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY || initialLimitType === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART) && - totalSpend >= card.nameValuePairs?.unapprovedExpenseLimit - ) { - shouldShowFixedOption = false; - } - } + const visibleLimitTypes = getVisibleExpensifyCardLimitTypes(card, policy); // Only link to the Workflows page when the current user can actually read it. Card admins without Workflows // access would otherwise be dropped onto the Not Found page. When they lack access, render plain (non-linked) text. @@ -205,7 +178,7 @@ function DynamicExpensifyCardLimitTypePage({route}: WorkspaceEditCardLimitTypePa isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY, }); - if (shouldShowFixedOption) { + if (visibleLimitTypes.includes(CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED)) { data.push({ value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED, label: translate('workspace.card.issueNewCard.fixedAmount'), @@ -215,7 +188,7 @@ function DynamicExpensifyCardLimitTypePage({route}: WorkspaceEditCardLimitTypePa }); } - if (card?.nameValuePairs?.isVirtual) { + if (visibleLimitTypes.includes(CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE)) { data.push({ value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE, label: translate('workspace.card.issueNewCard.singleUse'), diff --git a/src/pages/workspace/expensifyCard/DynamicExpensifyCardNamePage.tsx b/src/pages/workspace/expensifyCard/DynamicExpensifyCardNamePage.tsx index 7b521bb13c7b..cc5ef34006bb 100644 --- a/src/pages/workspace/expensifyCard/DynamicExpensifyCardNamePage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicExpensifyCardNamePage.tsx @@ -13,10 +13,9 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateExpensifyCardTitle} from '@libs/actions/Card'; -import {filterInactiveCardsForWorkspace} from '@libs/CardUtils'; -import {addErrorMessage} from '@libs/ErrorUtils'; +import {filterInactiveCardsForWorkspace, getCardNameError, getCardNameErrorMessage} from '@libs/CardUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {getFieldRequiredErrors, isValidInputLength} from '@libs/ValidationUtils'; +import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -50,18 +49,18 @@ function DynamicExpensifyCardNamePage({route}: DynamicExpensifyCardNamePageProps }, [backPath]); const submit = (values: FormOnyxValues) => { - updateExpensifyCardTitle(defaultFundID, Number(cardID), values[INPUT_IDS.NAME], card?.nameValuePairs?.cardTitle); + updateExpensifyCardTitle(defaultFundID, Number(cardID), StringUtils.sanitizeName(values[INPUT_IDS.NAME]), card?.nameValuePairs?.cardTitle); goBack(); }; const validate = (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [INPUT_IDS.NAME], translate); - if (values.name) { - const {isValid, byteLength} = isValidInputLength(values.name, CONST.STANDARD_LENGTH_LIMIT); - if (!isValid) { - addErrorMessage(errors, INPUT_IDS.NAME, translate('common.error.characterLimitExceedCounter', byteLength, CONST.STANDARD_LENGTH_LIMIT)); - } + const errors: FormInputErrors = {}; + const error = getCardNameError(values.name); + + if (error) { + errors[INPUT_IDS.NAME] = getCardNameErrorMessage(translate, error, values.name); } + return errors; }; diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx index 96d279fedb68..dcdb0aadc346 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx @@ -27,6 +27,7 @@ import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hook import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -128,7 +129,6 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen const StyleUtils = useStyleUtils(); const {showConfirmModal} = useConfirmModal(); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [session] = useOnyx(ONYXKEYS.SESSION); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -151,7 +151,7 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen policyID, fundID: defaultFundID, }); - const cardholder = personalDetails?.[card?.accountID ?? CONST.DEFAULT_NUMBER_ID]; + const [cardholder] = usePersonalDetail(card?.accountID); const isVirtual = !!card?.nameValuePairs?.isVirtual; const formattedAvailableSpendAmount = convertToDisplayString(card?.availableSpend, currency); const formattedLimit = convertToDisplayString(card?.nameValuePairs?.unapprovedExpenseLimit, currency); @@ -400,11 +400,9 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen value={card?.nameValuePairs?.cardTitle} /> - {spendRulesSummary.length > 0 && ( { + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}).then(() => { requestExpensifyCardLimitIncrease(settings?.paymentBankAccountID, defaultFundID); }); return; @@ -156,7 +158,7 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr }); } requestExpensifyCardLimitIncrease(settings?.paymentBankAccountID, defaultFundID); - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, shouldDismissModal: false}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); }; const isCurrentBalanceType = type === CONST.WORKSPACE_CARDS_LIST_LABEL_TYPE.CURRENT_BALANCE; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx index f79462302b54..d324d5c481b1 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx @@ -28,7 +28,7 @@ import type {SettingsNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import {configureExpensifyCardsForPolicy, setIssueNewCardStepAndData} from '@userActions/Card'; +import {clearIssueNewCardFormData, configureExpensifyCardsForPolicy, setIssueNewCardStepAndData} from '@userActions/Card'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -129,6 +129,7 @@ function WorkspaceExpensifyCardBankAccounts({route}: WorkspaceExpensifyCardBankA if (!policyID) { return; } + clearIssueNewCardFormData(); setIssueNewCardStepAndData({policyID, isChangeAssigneeDisabled: false}); Navigation.dismissModal(); Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.path, ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policyID))); diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx index 372466cfdadc..636b6131bcd4 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx @@ -1,3 +1,4 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; @@ -63,7 +64,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF const {translate} = useLocalize(); const {isOffline} = useNetwork(); const styles = useThemeStyles(); - const illustrations = useMemoizedLazyIllustrations(['ExpensifyCardImage']); + const illustrations = useMemoizedLazyIllustrations(['ExpensifyCardImage', 'Telescope']); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); @@ -207,8 +208,9 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF // Suppress the new-program branch on workspaces with unsupported currencies, and for members who cannot // reach the bank account setup page. These workspaces may only issue cards on existing feeds const shouldShowIssueCardButton = hasIssueCardFundID || (canEnrollNewCardProgram && canStartBankAccountSetup); + const shouldShowFooter = canWriteExpensifyCard && (shouldShowIssueCardButton || otherFeeds.length > 0); - const issueNewCardAndOtherFeedsFooter = canWriteExpensifyCard ? ( + const issueNewCardAndOtherFeedsFooter = shouldShowFooter ? ( {shouldShowIssueCardButton && ( ) : undefined; + const renderFeedListContent = () => { + if (primaryFeeds.length > 0) { + return ( + + ); + } + if (issueNewCardAndOtherFeedsFooter) { + return ( + + {issueNewCardAndOtherFeedsFooter} + + ); + } + return ( + + ); + }; + return ( - {primaryFeeds.length > 0 ? ( - - ) : ( - - {issueNewCardAndOtherFeedsFooter} - - )} + {renderFeedListContent()} ); diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 6677b96f52ee..de161969cb8c 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -6,6 +6,7 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import FeedSelector from '@components/FeedSelector'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; import type {WorkspaceExpensifyCardTableRowData} from '@components/Tables/WorkspaceExpensifyCardsTable'; import WorkspaceExpensifyCardsTable from '@components/Tables/WorkspaceExpensifyCardsTable'; @@ -13,8 +14,9 @@ import Text from '@components/Text'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrencyForExpensifyCard from '@hooks/useCurrencyForExpensifyCard'; -import useDefaultFundID from '@hooks/useDefaultFundID'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useEmptyViewHeaderHeight from '@hooks/useEmptyViewHeaderHeight'; import useExpensifyCardFeedsForFeedSelector from '@hooks/useExpensifyCardFeedsForFeedSelector'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -33,7 +35,23 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import {clearIssueNewCardFormData, exportExpensifyCardListToCSV, setIssueNewCardStepAndData} from '@libs/actions/Card'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {clearDeletePaymentMethodError} from '@libs/actions/PaymentMethods'; -import {getCardsByCardholderName, getCardSettings, isCurrencySupportedForECards} from '@libs/CardUtils'; +import { + canUpdateExpensifyCardLimitTypeInline, + getExpensifyCardLimitInlineUpdate, + renameExpensifyCardInline, + updateExpensifyCardLimitInline, + updateExpensifyCardLimitTypeInline, +} from '@libs/actions/Policy/InlineEdit'; +import { + getCardsByCardholderName, + getCardSettings, + getDefaultExpensifyCardLimitType, + getExpensifyCardLimitChangeWarningKey, + getExpensifyCardLimitTypeChangeWarningKey, + getExpensifyCardNewAvailableSpend, + isCurrencySupportedForECards, + shouldConfirmExpensifyCardLimitTypeChange, +} from '@libs/CardUtils'; import {getExpensifyCardFeedDescription} from '@libs/ExpensifyCardFeedSelectorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -51,11 +69,12 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {WorkspaceCardsList} from '@src/types/onyx'; +import type {Card, WorkspaceCardsList} from '@src/types/onyx'; +import type {CardLimitType} from '@src/types/onyx/Card'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import EmptyCardView from './EmptyCardView'; @@ -74,9 +93,10 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const {translate, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const isMobileSelectionModeEnabled = useMobileSelectionMode(); + const {convertToDisplayString} = useCurrencyListActions(); + const {showConfirmModal} = useConfirmModal(); const policyID = route.params.policyID; const policy = usePolicy(policyID); - const defaultFundID = useDefaultFundID(policyID); const [personalDetails] = useAllPersonalDetails(); const [cardOnWaitlist] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST}${policyID}`); const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${fundID}`); @@ -118,6 +138,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const shouldShowExportAccountColumn = !!cardExportSettings?.shouldShowMenuItem; const settlementCurrency = useCurrencyForExpensifyCard({policyID, fundID}); + const defaultLimitType = getDefaultExpensifyCardLimitType(policy); const shouldShowEuUkDisclaimer = isCurrencySupportedForECards(settlementCurrency); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); const allCards = useMemo(() => { @@ -125,11 +146,20 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp return getCardsByCardholderName(cardsList, policyMembersAccountIDs); }, [cardsList, policy?.employeeList, employeePersonalDetails]); + // Row click handlers close over the card from that render. Read the live list from a ref so + // no-op/confirm checks use the same card persist will use, and so confirm still persists + // current spend and rollback values if Onyx refreshes while the modal is open. + const cardsListRef = useRef(cardsList); + useEffect(() => { + cardsListRef.current = cardsList; + }, [cardsList]); + const isCardListEmpty = allCards.length === 0; const [selectedCardKeys, setSelectedCardKeys] = useState([]); const selectableCardKeySet = useMemo(() => new Set(allCards.map((card) => String(card.cardID))), [allCards]); const validatedSelectedCardKeys = useMemo(() => selectedCardKeys.filter((key) => selectableCardKeySet.has(key)), [selectedCardKeys, selectableCardKeySet]); const selectedCardIDs = useMemo(() => validatedSelectedCardKeys.map((key) => Number(key)), [validatedSelectedCardKeys]); + const isSelectionModeActive = selectedCardKeys.length > 0 || isMobileSelectionModeEnabled; const clearTableSelection = useCallback(() => { setSelectedCardKeys((prevSelectedCardKeys) => (prevSelectedCardKeys.length > 0 ? [] : prevSelectedCardKeys)); @@ -137,6 +167,72 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp useCleanupSelectedOptions(clearTableSelection); + const changeCardLimitType = useCallback( + (card: Card, newLimitType: CardLimitType) => { + const latestCard = cardsListRef.current?.[String(card.cardID)] ?? card; + if (!canUpdateExpensifyCardLimitTypeInline(latestCard, newLimitType, defaultLimitType)) { + return; + } + + const persistLimitType = () => updateExpensifyCardLimitTypeInline(fundID, cardsListRef.current?.[String(card.cardID)] ?? latestCard, newLimitType, defaultLimitType); + + if (!shouldConfirmExpensifyCardLimitTypeChange(latestCard, newLimitType, defaultLimitType)) { + persistLimitType(); + return; + } + + showConfirmModal({ + title: translate('workspace.expensifyCard.changeCardLimitType'), + prompt: translate( + getExpensifyCardLimitTypeChangeWarningKey(latestCard.nameValuePairs?.limitType ?? defaultLimitType), + convertToDisplayString(latestCard.nameValuePairs?.unapprovedExpenseLimit, settlementCurrency), + ), + confirmText: translate('workspace.expensifyCard.changeLimitType'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + shouldEnableNewFocusManagement: true, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + persistLimitType(); + }); + }, + [convertToDisplayString, defaultLimitType, fundID, settlementCurrency, showConfirmModal, translate], + ); + + const changeCardLimit = useCallback( + (card: Card, newLimit: string) => { + const latestCard = cardsListRef.current?.[String(card.cardID)] ?? card; + const nextLimit = getExpensifyCardLimitInlineUpdate(latestCard, newLimit); + if (nextLimit === undefined) { + return; + } + + const persistLimit = () => updateExpensifyCardLimitInline(fundID, cardsListRef.current?.[String(card.cardID)] ?? latestCard, newLimit); + + if (getExpensifyCardNewAvailableSpend(latestCard, nextLimit) > 0) { + persistLimit(); + return; + } + + showConfirmModal({ + title: translate('workspace.expensifyCard.changeCardLimit'), + prompt: translate(getExpensifyCardLimitChangeWarningKey(latestCard.nameValuePairs?.limitType ?? defaultLimitType), convertToDisplayString(nextLimit, settlementCurrency)), + confirmText: translate('workspace.expensifyCard.changeLimit'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + shouldEnableNewFocusManagement: true, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + persistLimit(); + }); + }, + [convertToDisplayString, defaultLimitType, fundID, settlementCurrency, showConfirmModal, translate], + ); + const cardRows = useMemo( () => allCards.map((card) => { @@ -149,6 +245,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp formatPhoneNumber, }) || undefined : undefined; + const canEditCard = canWriteExpensifyCard && card.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isSelectionModeActive; return { keyForList: String(card.cardID), @@ -168,11 +265,30 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp frozenDate: card.nameValuePairs?.frozen?.date, errors: card.errors, pendingAction: card.pendingAction, + canEditName: canEditCard, + canEditLimitType: canEditCard, + canEditLimit: canEditCard, action: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_DETAILS.getRoute(card.cardID.toString()))), - onClose: () => clearDeletePaymentMethodError(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, card.cardID), + onRenameName: (newName: string) => renameExpensifyCardInline(fundID, card.cardID, newName, card.nameValuePairs?.cardTitle ?? ''), + onChangeLimitType: (newLimitType: CardLimitType) => changeCardLimitType(card, newLimitType), + onChangeLimit: (newLimit: string) => changeCardLimit(card, newLimit), + onClose: () => clearDeletePaymentMethodError(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${fundID}_${CONST.EXPENSIFY_CARD.BANK}`, card.cardID), }; }), - [allCards, cardExportSettings, shouldShowExportAccountColumn, defaultFundID, personalDetails, settlementCurrency, translate, formatPhoneNumber], + [ + allCards, + canWriteExpensifyCard, + cardExportSettings, + changeCardLimit, + changeCardLimitType, + formatPhoneNumber, + fundID, + isSelectionModeActive, + personalDetails, + settlementCurrency, + shouldShowExportAccountColumn, + translate, + ], ); const bulkExportOptions: Array> = [ @@ -187,6 +303,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp cards: selectedCards, personalDetailsList: personalDetails, settlementCurrency, + defaultLimitType, translate, formatPhoneNumber, }); @@ -366,6 +483,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp 0} diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx index 2162d77454ca..ad168b77419b 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx @@ -21,12 +21,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {clearIssueNewCardFormData} from '@libs/actions/Card'; -import {getEligibleBankAccountsForCard, getEligibleBankAccountsForUkEuCard} from '@libs/CardUtils'; +import {getExpensifyCardEnrollmentRoute} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {canEditWorkspaceSettings} from '@libs/PolicyUtils'; -import {hasInProgressUSDVBBA} from '@libs/ReimbursementAccountUtils'; +import {hasInProgressVBBA} from '@libs/ReimbursementAccountUtils'; import Navigation from '@navigation/Navigation'; @@ -65,7 +65,7 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif const {canWrite: canWriteExpensifyCard, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); - const isSetupUnfinished = hasInProgressUSDVBBA(reimbursementAccount?.achData); + const isSetupUnfinished = !!policy?.id && hasInProgressVBBA(reimbursementAccount?.achData, policy.outputCurrency !== CONST.CURRENCY.USD, policy.id); const {canEnrollNewCardProgram, isUkEuCurrencySupported} = useCanEnrollNewExpensifyCardProgram(policy?.id); const shouldBlockCurrencyChange = useShouldBlockCurrencyChange(policy?.id); @@ -84,34 +84,30 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif const setupCtaTranslationKey = isSetupUnfinished ? 'workspace.expensifyCard.finishSetup' : 'workspace.expensifyCard.issueNewCard'; const ctaTextTranslationKey = hasAccessibleFeeds ? 'workspace.moreFeatures.expensifyCard.feed.viewCards' : setupCtaTranslationKey; - const eligibleBankAccounts = isUkEuCurrencySupported - ? getEligibleBankAccountsForUkEuCard(bankAccountList, supportedCountriesByCurrency, policy?.outputCurrency) - : getEligibleBankAccountsForCard(bankAccountList); - const shouldStartBankAccountSetup = !eligibleBankAccounts.length || isSetupUnfinished; const canEditSettings = canEditWorkspaceSettings(policy, currentUserLogin); // Without an existing feed the only path forward is enrolling a new card program, and both the // bank account setup page and the currency page are admin only const shouldDisableCTA = !canWriteExpensifyCard || (!hasAccessibleFeeds && !canEditSettings); const startFlow = () => { - if (hasAccessibleFeeds && policy?.id) { - clearIssueNewCardFormData(); - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_SELECT_FEED.path, ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policy.id))); + if (!policy?.id) { return; } - if (shouldStartBankAccountSetup) { - Navigation.navigate( - ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute({ - policyID: policy?.id, - backTo: ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policy?.id), - }), - ); - return; - } - if (policy?.id) { + if (hasAccessibleFeeds) { clearIssueNewCardFormData(); Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_SELECT_FEED.path, ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policy.id))); + return; } + Navigation.navigate( + getExpensifyCardEnrollmentRoute({ + policyID: policy.id, + currencyCode: policy.outputCurrency, + isUkEuCurrencySupported, + bankAccountsList: bankAccountList, + supportedCountriesByCurrency, + achData: reimbursementAccount?.achData, + }), + ); }; const expensifyCardFeatures: FeatureListItem[] = [ @@ -143,7 +139,7 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif if (shouldBlockCurrencyChange || result.action !== ModalActions.CONFIRM || !policy) { return; } - Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_CURRENCY.getRoute(policy.id)); + Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_CURRENCY.getRoute(policy.id, {shouldStartExpensifyCardEnrollment: true})); }; return ( diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index 86de9913cd51..002024bd7cae 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -19,10 +19,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {searchUserInServer} from '@libs/actions/Report'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; +import {getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; import {canMemberWrite, filterGuideAndAccountManager, getGuideAndAccountManagerInfo, getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; +import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; import Navigation from '@navigation/Navigation'; diff --git a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx index 5328de575d8d..9a56227ab942 100644 --- a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx @@ -12,10 +12,9 @@ import useOnyx from '@hooks/useOnyx'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getDefaultCardName} from '@libs/CardUtils'; -import {addErrorMessage} from '@libs/ErrorUtils'; +import {getCardNameError, getCardNameErrorMessage, getDefaultCardName} from '@libs/CardUtils'; import {isPolicyFeatureEnabled} from '@libs/PolicyUtils'; -import {getFieldRequiredErrors, isValidInputLength} from '@libs/ValidationUtils'; +import StringUtils from '@libs/StringUtils'; import {setIssueNewCardStepAndData} from '@userActions/Card'; @@ -54,13 +53,13 @@ function CardNameStep({policyID, stepNames, startStepIndex}: CardNameStepProps) const defaultCardTitle = !isVirtualCard ? getDefaultCardName(userName) : ''; const validate = (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [INPUT_IDS.CARD_TITLE], translate); - if (values.cardTitle) { - const {isValid, byteLength} = isValidInputLength(values.cardTitle, CONST.STANDARD_LENGTH_LIMIT); - if (!isValid) { - addErrorMessage(errors, INPUT_IDS.CARD_TITLE, translate('common.error.characterLimitExceedCounter', byteLength, CONST.STANDARD_LENGTH_LIMIT)); - } + const errors: FormInputErrors = {}; + const error = getCardNameError(values.cardTitle); + + if (error) { + errors[INPUT_IDS.CARD_TITLE] = getCardNameErrorMessage(translate, error, values.cardTitle); } + return errors; }; @@ -70,7 +69,7 @@ function CardNameStep({policyID, stepNames, startStepIndex}: CardNameStepProps) setIssueNewCardStepAndData({ step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, data: { - cardTitle: values.cardTitle, + cardTitle: StringUtils.sanitizeName(values.cardTitle), }, isEditing: false, policyID, diff --git a/src/pages/workspace/fields/FieldsInitialValuePage.tsx b/src/pages/workspace/fields/FieldsInitialValuePage.tsx index 9564a512f4cb..b5ab10c08958 100644 --- a/src/pages/workspace/fields/FieldsInitialValuePage.tsx +++ b/src/pages/workspace/fields/FieldsInitialValuePage.tsx @@ -12,7 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {hasCircularReferences} from '@libs/Formula'; import Navigation from '@libs/Navigation/Navigation'; -import {getReportFieldKey} from '@libs/ReportUtils'; +import {getReportFieldKey, hasEnabledListValue} from '@libs/ReportUtils'; import {isRequiredFulfilled} from '@libs/ValidationUtils'; import {getReportFieldInitialValue, getReportFieldsForTarget, getUnsupportedReportFieldFormulaParts, isReportFieldTargetValid} from '@libs/WorkspaceReportFieldUtils'; @@ -49,7 +49,7 @@ function FieldsInitialValuePage({policy, policyID, reportFieldID, featureName, e const reportField = policy?.fieldList?.[getReportFieldKey(reportFieldID)] ?? null; const isInvoiceField = reportField?.target === CONST.REPORT_FIELD_TARGETS.INVOICE; - const availableListValuesLength = (reportField?.disabledOptions ?? []).filter((disabledListValue) => !disabledListValue).length; + const hasAvailableListValues = !!reportField && hasEnabledListValue(reportField); const currentInitialValue = getReportFieldInitialValue(reportField, translate); const [initialValue, setInitialValue] = useState(currentInitialValue); @@ -91,7 +91,7 @@ function FieldsInitialValuePage({policy, policyID, reportFieldID, featureName, e } } - if (reportField?.type === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength > 0 && !isRequiredFulfilled(formInitialValue)) { + if (reportField?.type === CONST.REPORT_FIELD_TYPES.LIST && hasAvailableListValues && !isRequiredFulfilled(formInitialValue)) { errors[INPUT_IDS.INITIAL_VALUE] = translate( isInvoiceField ? 'workspace.invoiceFields.invoiceFieldInitialValueRequiredError' : 'workspace.reportFields.reportFieldInitialValueRequiredError', ); @@ -99,7 +99,7 @@ function FieldsInitialValuePage({policy, policyID, reportFieldID, featureName, e return errors; }, - [availableListValuesLength, expectedTarget, isInvoiceField, reportField?.name, reportField?.type, policy?.fieldList, translate], + [hasAvailableListValues, expectedTarget, isInvoiceField, reportField?.name, reportField?.type, policy?.fieldList, translate], ); if (!reportField || !isReportFieldTargetValid(reportField, expectedTarget)) { diff --git a/src/pages/workspace/fields/FieldsSettingsPage.tsx b/src/pages/workspace/fields/FieldsSettingsPage.tsx index df78ada9a13a..d30eabdf8825 100644 --- a/src/pages/workspace/fields/FieldsSettingsPage.tsx +++ b/src/pages/workspace/fields/FieldsSettingsPage.tsx @@ -12,7 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PolicyFeature} from '@libs/PolicyUtils'; -import {getReportFieldKey} from '@libs/ReportUtils'; +import {getReportFieldKey, hasEnabledListValue} from '@libs/ReportUtils'; import {getReportFieldInitialValue, getReportFieldTypeTranslationKey, isReportFieldImportedFromIntegration, isReportFieldTargetValid} from '@libs/WorkspaceReportFieldUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -75,7 +75,7 @@ function FieldsSettingsPage({ const isDateFieldType = reportField.type === CONST.REPORT_FIELD_TYPES.DATE; const isListFieldType = reportField.type === CONST.REPORT_FIELD_TYPES.LIST; - const isListFieldEmpty = isListFieldType && reportField.disabledOptions.filter((disabledListValue) => !disabledListValue).length <= 0; + const isListFieldEmpty = isListFieldType && !hasEnabledListValue(reportField); const listValues = Object.values(policy?.fieldList?.[reportFieldKey]?.values ?? {})?.sort(localeCompare); const deleteReportFieldAndHideModal = () => { diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index e2eb8a0b85af..1f80a8a00759 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -20,6 +20,7 @@ import { isMCPEnabled, isPerDiemEnabled, isPolicyAdmin, + isQBORefreshTokenExpiringSoonSelector, isTimeTrackingEnabled, shouldShowEmployeeListError, shouldShowSyncError, @@ -100,6 +101,8 @@ type GetWorkspaceMenuItemsParams = { isConnectionInProgress?: boolean; /** Categories used to determine category-related errors. */ policyCategories?: OnyxTypes.PolicyCategories; + /** Whether any of the policy's merchant rules failed to save, used to surface a red dot on the Rules row. */ + hasMerchantRuleErrors?: boolean; /** Previous pending fields used to identify the most recently enabled feature. */ previousPendingFields?: OnyxTypes.Policy['pendingFields']; /** Whether receipt partner credentials require attention. */ @@ -121,6 +124,7 @@ function getWorkspaceMenuItems({ icons, isConnectionInProgress = false, policyCategories, + hasMerchantRuleErrors, previousPendingFields, shouldShowEnterCredentialsError = false, shouldShowRBR = false, @@ -227,11 +231,17 @@ function getWorkspaceMenuItems({ } if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED] && canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.ACCOUNTING)) { + let accountingBrickRoadIndicator; + if (hasSyncError || shouldShowQBOReimbursableExportDestinationAccountError(policy)) { + accountingBrickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } else if (isQBORefreshTokenExpiringSoonSelector(policy)) { + accountingBrickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; + } items.push({ translationKey: 'workspace.common.accounting', icon: icons.Sync, getRoute: () => ROUTES.POLICY_ACCOUNTING.getRoute(policyID), - brickRoadIndicator: hasSyncError || shouldShowQBOReimbursableExportDestinationAccountError(policy) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + brickRoadIndicator: accountingBrickRoadIndicator, screenName: SCREENS.WORKSPACE.ACCOUNTING.ROOT, sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.ACCOUNTING, highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED, @@ -346,7 +356,7 @@ function getWorkspaceMenuItems({ translationKey: 'workspace.common.rules', icon: icons.Bolt, getRoute: () => ROUTES.WORKSPACE_RULES.getRoute(policyID), - brickRoadIndicator: hasPolicyRulesError(policy) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + brickRoadIndicator: hasPolicyRulesError(policy, hasMerchantRuleErrors ?? false) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, screenName: SCREENS.WORKSPACE.RULES, sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.RULES, highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED, diff --git a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx index 805158fc8d66..61928054b960 100644 --- a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx +++ b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx @@ -56,6 +56,7 @@ function MergeHRGroupsPage({ keyForList: group.id, value: group.id, isSelected: selectedIds.has(group.id), + itemStyle: styles.pv4, })); const toggleItem = (item: GroupListItem) => { @@ -117,7 +118,7 @@ function MergeHRGroupsPage({ onChangeText: setSearchText, style: {containerStyle: styles.pb5}, }} - style={{listHeaderSelectAllTextStyle: styles.textLabelSupporting, listItemWrapperStyle: styles.pv4}} + style={{listHeaderSelectAllTextStyle: styles.textLabelSupporting}} /> & + PlatformStackScreenProps & { + personalDetails: OnyxEntry; + }; + +function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPageProps) { + const accountID = Number(route.params.accountID); + const policyID = route.params.policyID; + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + const isWorkArrangementBetaEnabled = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS); + + const memberLogin = personalDetails?.[accountID]?.login ?? ''; + const member = policy?.employeeList?.[memberLogin]; + const canWriteMembers = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS); + const canAccessWorkArrangementPage = canWriteMembers && isWorkArrangementBetaEnabled && isMemberInHomeAndOfficeWorkspace(policy, memberLogin); + + // The member-level setting wins; otherwise fall back to the workspace default, then to no regular workspace. + const currentIsOffice = getEffectiveWorkArrangement(member?.hasOfficeWorkArrangement, policy?.commuterExclusions?.isOfficeWorkArrangement); + + const navigateBackToDetails = () => { + Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + }; + + const changeWorkArrangement = ({value}: WorkArrangementOption) => { + if (value === currentIsOffice || !canAccessWorkArrangementPage) { + return; + } + setEmployeeWorkArrangement(policy, [accountID], value, personalDetails, translate); + navigateBackToDetails(); + }; + + const options: WorkArrangementOption[] = [ + { + value: true, + text: getWorkArrangementLabel(translate, true), + alternateText: translate('workspace.people.workArrangementPage.optionOfficeBasedHelp'), + isSelected: currentIsOffice, + keyForList: 'office-based', + }, + { + value: false, + text: getWorkArrangementLabel(translate, false), + alternateText: translate('workspace.people.workArrangementPage.optionNoRegularWorkspaceHelp'), + isSelected: !currentIsOffice, + keyForList: 'no-regular-workplace', + }, + ]; + + return ( + + + + + + + {translate('workspace.people.workArrangementPage.futureOnlyNote')} + + + + + ); +} + +export default withPolicyAndFullscreenLoading(WorkArrangementPage); diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index 77411a4e7801..bcaa493c38f2 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -14,6 +14,7 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePersonalDetailByLogin, {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -22,6 +23,7 @@ import {clearDraftValues} from '@libs/actions/FormActions'; import {openExternalLink} from '@libs/actions/Link'; import {addMembersToWorkspace, clearWorkspaceInviteApproverDraft, clearWorkspaceInviteRoleDraft} from '@libs/actions/Policy/Member'; import {setWorkspaceInviteMessageDraft} from '@libs/actions/Policy/Policy'; +import {saveFastEditApprovalWorkflow} from '@libs/actions/Workflow'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getNewAccountIDsAndLogins, getPersonalDetailsForAccountIDs, getPersonalDetailsOnyxDataForOptimisticUsers, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; @@ -38,6 +40,7 @@ import { import {getAllPolicyExpenseChatReportActions} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import {getSearchParamFromPath} from '@libs/Url'; +import {filterRulesForPolicy} from '@libs/WorkflowUtils'; import variables from '@styles/variables'; @@ -49,10 +52,11 @@ import type {Route as Routes} from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/WorkspaceInviteMessageForm'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type Policy from '@src/types/onyx/Policy'; +import type Rule from '@src/types/onyx/Rule'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {GestureResponderEvent} from 'react-native/Libraries/Types/CoreEventTypes'; import {Str} from 'expensify-common'; @@ -95,6 +99,11 @@ function WorkspaceInviteMessageComponent({ const [allPersonalDetails] = useAllPersonalDetails(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + // Only used when this page finishes a "+N more" workflow edit, which has no edit page behind it to save the workflow. + const [approvalWorkflow] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); + const policyRulesSelector = useCallback((rules: OnyxCollection) => filterRulesForPolicy(rules, policyID), [policyID]); + const [rulesCollection] = useOnyx(ONYXKEYS.COLLECTION.RULE, {selector: policyRulesSelector}); + const {isBetaEnabled} = usePermissions(); const [welcomeNote, setWelcomeNote] = useState(); @@ -216,6 +225,11 @@ function WorkspaceInviteMessageComponent({ const nestedBackTo = getSearchParamFromPath(backTo?.toString() ?? '', 'backTo'); if (nestedBackTo) { Navigation.goBack(nestedBackTo as Routes); + } else if (approvalWorkflow?.isFastEdit) { + Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID), { + afterTransition: () => + saveFastEditApprovalWorkflow({approvalWorkflow, policy, rules: rulesCollection, isMultipleApproversBetaEnabled: isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS)}), + }); } else { // forceReplace so the invite page is removed from the stack. Otherwise it stays // underneath the Approver page and an iOS swipe-back reopens the invite confirm page. diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index ca0baf2fba65..d4a44bb95fbb 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -22,6 +22,7 @@ import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import usePrevious from '@hooks/usePrevious'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; @@ -38,20 +39,13 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getPhoneNumber, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; -import { - canMemberAssignRole, - canMemberManageMemberWithRole, - canMemberWrite, - getReimburserEmail, - isControlPolicy, - isPolicyApprover, - PAYER_ROLES, - tryNavigateToSubmitWorkspaceUpgrade, -} from '@libs/PolicyUtils'; +import {isPolicyReimburser} from '@libs/PolicyMemberRoleUtils'; +import {canMemberAssignRole, canMemberManageMemberWithRole, canMemberWrite, isControlPolicy, isPolicyApprover, PAYER_ROLES, tryNavigateToSubmitWorkspaceUpgrade} from '@libs/PolicyUtils'; import {isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import {generateAccountID} from '@libs/UserUtils'; +import {getEffectiveWorkArrangement, getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; import {convertPolicyEmployeesToApprovalWorkflows, updateWorkflowDataOnApproverRemoval} from '@libs/WorkflowUtils'; import Navigation from '@navigation/Navigation'; @@ -116,6 +110,8 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const {convertToDisplayString} = useCurrencyListActions(); const icons = useMemoizedLazyExpensifyIcons(['RemoveMembers', 'Info', 'Transfer']); const styles = useThemeStyles(); + const {isBetaEnabled} = usePermissions(); + const isWorkArrangementBetaEnabled = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS); const {formatPhoneNumber, translate, localeCompare} = useLocalize(); const StyleUtils = useStyleUtils(); const illustrations = useThemeIllustrations(); @@ -136,6 +132,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); const accountID = memberPersonalDetails?.accountID ?? routeAccountID; const member = policy?.employeeList?.[memberLogin]; + const memberWorkArrangement = getEffectiveWorkArrangement(member?.hasOfficeWorkArrangement, policy?.commuterExclusions?.isOfficeWorkArrangement); const prevMember = usePrevious(member); const details = memberPersonalDetails ?? ({} as PersonalDetails); const fallbackIcon = details.fallbackIcon ?? ''; @@ -154,8 +151,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const isSMSLogin = Str.isSMSLogin(memberLogin); const phoneNumber = getPhoneNumber(details); const memberLoginToCopy = isSMSLogin ? formatPhoneNumber(phoneNumber ?? '') : memberLogin; - const reimburserEmail = getReimburserEmail(policy); - const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; + const isReimburser = isPolicyReimburser(policy, memberLogin); // Only let the Authorized Payer change roles when there is another payer role they can actually move to. const assignablePayerRoles = PAYER_ROLES.filter((payerRole) => canMemberAssignRole(policy, currentUserLogin, payerRole)); const canReimburserChangeRole = assignablePayerRoles.some((payerRole) => payerRole !== member?.role); @@ -412,6 +408,19 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, accountID)); }} /> + {policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && isWorkArrangementBetaEnabled && ( + Navigation.navigate(ROUTES.WORKSPACE_MEMBER_WORK_ARRANGEMENT.getRoute(policyID, accountID))} + /> + )} {isControlPolicy(policy) && ( <> diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 05ff7f67751b..6cf74e079b4c 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -11,7 +11,8 @@ import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {canMemberAssignRole, canRolePay, getReimburserEmail, PAYER_ROLES} from '@libs/PolicyUtils'; +import {getAllowedRolesForMember, isPolicyReimburser} from '@libs/PolicyMemberRoleUtils'; +import {canMemberAssignRole, canRolePay} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -40,9 +41,8 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const member = policy?.employeeList?.[memberLogin]; const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to the roles that can pay (for example Admin or Payments Admin). - const reimburserEmail = getReimburserEmail(policy); - const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - const allowedRoles = isReimburser ? [...PAYER_ROLES] : undefined; + const isReimburser = isPolicyReimburser(policy, memberLogin); + const allowedRoles = getAllowedRolesForMember(policy, memberLogin); useRedirectSubmitWorkspaceFeatureUpgrade({ policy, backTo: ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID), diff --git a/src/pages/workspace/merge/MergeFinalApproverPageBase.tsx b/src/pages/workspace/merge/MergeFinalApproverPageBase.tsx index c623b2770f21..aa9768f0e070 100644 --- a/src/pages/workspace/merge/MergeFinalApproverPageBase.tsx +++ b/src/pages/workspace/merge/MergeFinalApproverPageBase.tsx @@ -1,4 +1,4 @@ -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import ScreenWrapper from '@components/ScreenWrapper'; import WorkspaceMembersSelectionList from '@components/WorkspaceMembersSelectionList'; @@ -57,7 +57,7 @@ function MergeFinalApproverPageBase({policyID, config}: MergeFinalApproverPageBa testID={config.testID} shouldEnableMaxHeight > - Navigation.goBack(config.backRoute)} /> diff --git a/src/pages/workspace/merge/MergeSyncResultsPageBase.tsx b/src/pages/workspace/merge/MergeSyncResultsPageBase.tsx index 633634a897f8..412770dc85e4 100644 --- a/src/pages/workspace/merge/MergeSyncResultsPageBase.tsx +++ b/src/pages/workspace/merge/MergeSyncResultsPageBase.tsx @@ -7,7 +7,7 @@ */ import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import Icon from '@components/Icon'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -115,7 +115,7 @@ function MergeSyncResultsPageBase({policyID, category}: MergeSyncResultsPageBase testID={testID} enableEdgeToEdgeBottomSafeAreaPadding > - diff --git a/src/pages/workspace/perDiem/EditPerDiemAmountPage.tsx b/src/pages/workspace/perDiem/EditPerDiemAmountPage.tsx index 93acaa39d97b..0ab7ccc5e064 100644 --- a/src/pages/workspace/perDiem/EditPerDiemAmountPage.tsx +++ b/src/pages/workspace/perDiem/EditPerDiemAmountPage.tsx @@ -13,6 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {convertToBackendAmount, convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {getPerDiemAmountError, getPerDiemAmountErrorMessage} from '@libs/PolicyPerDiemUtils'; import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -52,11 +53,10 @@ function EditPerDiemAmountPage({route}: EditPerDiemAmountPageProps) { const validate = (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; + const amountError = getPerDiemAmountError(values.amount); - const newAmount = values.amount.trim(); - const backendAmount = newAmount ? convertToBackendAmount(Number(newAmount)) : 0; - if (backendAmount === 0 || newAmount === '-') { - errors.amount = translate('common.error.fieldRequired'); + if (amountError) { + errors.amount = getPerDiemAmountErrorMessage(translate, amountError); } return errors; diff --git a/src/pages/workspace/perDiem/EditPerDiemDestinationPage.tsx b/src/pages/workspace/perDiem/EditPerDiemDestinationPage.tsx index fb2099ec15ef..5a4b97007506 100644 --- a/src/pages/workspace/perDiem/EditPerDiemDestinationPage.tsx +++ b/src/pages/workspace/perDiem/EditPerDiemDestinationPage.tsx @@ -13,7 +13,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {getPerDiemNameError, getPerDiemNameErrorMessage} from '@libs/PolicyPerDiemUtils'; import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import StringUtils from '@libs/StringUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -50,13 +52,10 @@ function EditPerDiemDestinationPage({route}: EditPerDiemDestinationPageProps) { const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; + const nameError = getPerDiemNameError(values.destination); - const destinationTrimmed = values.destination.trim(); - - if (!destinationTrimmed) { - errors.destination = translate('common.error.fieldRequired'); - } else if (destinationTrimmed.length > CONST.MAX_LENGTH_256) { - errors.destination = translate('common.error.characterLimitExceedCounter', destinationTrimmed.length, CONST.MAX_LENGTH_256); + if (nameError) { + errors.destination = getPerDiemNameErrorMessage(translate, nameError, values.destination); } return errors; @@ -66,7 +65,7 @@ function EditPerDiemDestinationPage({route}: EditPerDiemDestinationPageProps) { const editDestination = useCallback( (values: FormOnyxValues) => { - const newDestination = values.destination.trim(); + const newDestination = StringUtils.sanitizeName(values.destination); if (newDestination !== selectedRate?.name) { editPerDiemRateDestination(policyID, rateID, customUnit, newDestination); } diff --git a/src/pages/workspace/perDiem/EditPerDiemSubratePage.tsx b/src/pages/workspace/perDiem/EditPerDiemSubratePage.tsx index 70749c50c2d7..907f51738f1c 100644 --- a/src/pages/workspace/perDiem/EditPerDiemSubratePage.tsx +++ b/src/pages/workspace/perDiem/EditPerDiemSubratePage.tsx @@ -12,7 +12,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {getPerDiemNameError, getPerDiemNameErrorMessage} from '@libs/PolicyPerDiemUtils'; import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import StringUtils from '@libs/StringUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -48,20 +50,17 @@ function EditPerDiemSubratePage({route}: EditPerDiemSubratePageProps) { const validate = (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; + const nameError = getPerDiemNameError(values.subrate); - const subrateTrimmed = values.subrate.trim(); - - if (!subrateTrimmed) { - errors.subrate = translate('common.error.fieldRequired'); - } else if (subrateTrimmed.length > CONST.MAX_LENGTH_256) { - errors.subrate = translate('common.error.characterLimitExceedCounter', subrateTrimmed.length, CONST.MAX_LENGTH_256); + if (nameError) { + errors.subrate = getPerDiemNameErrorMessage(translate, nameError, values.subrate); } return errors; }; const editSubrate = (values: FormOnyxValues) => { - const newSubrate = values.subrate.trim(); + const newSubrate = StringUtils.sanitizeName(values.subrate); if (newSubrate !== selectedSubrate?.name) { editPerDiemRateSubrate(policyID, rateID, subRateID, customUnit, newSubrate); } diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx index 03f0b52fbf95..508a4a56209c 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx @@ -26,6 +26,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {renamePerDiemDestinationInline, renamePerDiemSubrateInline, updatePerDiemAmountInline} from '@libs/actions/Policy/InlineEdit'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -176,12 +177,15 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { const hasVisibleSubRates = allSubRates.some((subRate) => subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); + const isSelectionModeActive = selectedSubRateKeys.length > 0 || isMobileSelectionModeEnabled; + const perDiemRows: PerDiemTableRowData[] = useMemo( () => allSubRates .filter((subRate) => isOffline || subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map((subRate) => { const isDeleting = subRate.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const canEdit = canWritePerDiem && !isDeleting && !isSelectionModeActive; return { keyForList: getPerDiemRowKey(subRate.rateID, subRate.subRateID), @@ -190,13 +194,20 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { destination: subRate.destination, subRateName: subRate.subRateName, rate: subRate.rate, + currency: subRate.currency, formattedAmount: convertAmountToDisplayString(subRate.rate, subRate.currency), disabled: isDeleting, pendingAction: subRate.pendingAction, + canEditDestination: canEdit, + canEditSubrate: canEdit, + canEditAmount: canEdit, action: () => openSubRateDetails(subRate.rateID, subRate.subRateID), + onRenameDestination: (newName: string) => renamePerDiemDestinationInline(policyID, subRate.rateID, customUnit, subRate.destination, newName), + onRenameSubrate: (newName: string) => renamePerDiemSubrateInline(policyID, subRate.rateID, subRate.subRateID, customUnit, subRate.subRateName, newName), + onChangeAmount: (newAmount: string) => updatePerDiemAmountInline(policyID, subRate.rateID, subRate.subRateID, customUnit, subRate.rate, newAmount), }; }), - [allSubRates, isOffline, openSubRateDetails], + [allSubRates, isOffline, openSubRateDetails, canWritePerDiem, isSelectionModeActive, policyID, customUnit], ); const secondaryActions = useMemo(() => { diff --git a/src/pages/workspace/receiptPartners/ChangeReceiptBillingAccountPage.tsx b/src/pages/workspace/receiptPartners/ChangeReceiptBillingAccountPage.tsx index bedf1793295d..5f4f0eaea638 100644 --- a/src/pages/workspace/receiptPartners/ChangeReceiptBillingAccountPage.tsx +++ b/src/pages/workspace/receiptPartners/ChangeReceiptBillingAccountPage.tsx @@ -18,10 +18,11 @@ import {changePolicyUberBillingAccount} from '@libs/actions/Policy/Policy'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {formatMemberForList, getHeaderMessage, getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; +import {formatMemberForList, getHeaderMessage, getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import type {MemberForList} from '@libs/OptionsListUtils'; import {isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; +import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; diff --git a/src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx b/src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx index 5871caf7014e..8b1cfeeb7dee 100644 --- a/src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx +++ b/src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx @@ -23,9 +23,10 @@ import {clearUberEmployeeError, inviteWorkspaceEmployeesToUber} from '@libs/acti import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {formatMemberForList, getHeaderMessage, sortAlphabetically} from '@libs/OptionsListUtils'; +import {formatMemberForList, getHeaderMessage} from '@libs/OptionsListUtils'; import type {MemberForList} from '@libs/OptionsListUtils'; import {isDeletedPolicyEmployee} from '@libs/PolicyUtils'; +import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -206,6 +207,7 @@ function DynamicEditInviteReceiptPartnerPolicyPage({route}: DynamicEditInviteRec ...option, rightElement, errors: uberEmployeesByEmail[email]?.errors, + itemStyle: styles.cursorDefault, }; list.push(optionWithErrorsAndRightElement as MemberForList & ListItem); @@ -335,7 +337,7 @@ function DynamicEditInviteReceiptPartnerPolicyPage({route}: DynamicEditInviteRec ListItem={BareUserListItem} onSelectRow={() => {}} onDismissError={dismissError} - style={{listItemWrapperStyle: styles.cursorDefault, listStyle: styles.mt3}} + style={{listStyle: styles.mt3}} addBottomSafeAreaPadding shouldShowTextInput={shouldShowTextInput} textInputOptions={{ diff --git a/src/pages/workspace/receiptPartners/DynamicInviteReceiptPartnerPolicyPage.tsx b/src/pages/workspace/receiptPartners/DynamicInviteReceiptPartnerPolicyPage.tsx index f9b86146be35..907e06819f86 100644 --- a/src/pages/workspace/receiptPartners/DynamicInviteReceiptPartnerPolicyPage.tsx +++ b/src/pages/workspace/receiptPartners/DynamicInviteReceiptPartnerPolicyPage.tsx @@ -18,9 +18,10 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearErrors, inviteWorkspaceEmployeesToUber} from '@libs/actions/Policy/Policy'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {formatMemberForList, getHeaderMessage, getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; +import {formatMemberForList, getHeaderMessage, getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import type {MemberForList} from '@libs/OptionsListUtils'; import {isDeletedPolicyEmployee} from '@libs/PolicyUtils'; +import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; diff --git a/src/pages/workspace/recruiting/approver/MergeATSApprovalModePage.tsx b/src/pages/workspace/recruiting/approver/MergeATSApprovalModePage.tsx index 096cfc1133d1..3283f3e5e880 100644 --- a/src/pages/workspace/recruiting/approver/MergeATSApprovalModePage.tsx +++ b/src/pages/workspace/recruiting/approver/MergeATSApprovalModePage.tsx @@ -1,6 +1,6 @@ import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import RenderHTML from '@components/RenderHTML'; @@ -141,7 +141,7 @@ function MergeATSApprovalModePage({ enableEdgeToEdgeBottomSafeAreaPadding testID="MergeATSApprovalModePage" > - + {translate('workspace.recruiting.approvalModeDescription', providerName)} @@ -151,7 +151,6 @@ function MergeATSApprovalModePage({ item={option} showTooltip={false} onSelectRow={() => setDraftApprovalMode(option.value)} - isAlternateTextMultilineSupported alternateTextNumberOfLines={3} /> {option.value === selectedApprovalMode && selectedApprovalMode === CONST.MERGE.APPROVAL_MODE.BASIC && ( diff --git a/src/pages/workspace/recruiting/approver/MergeATSApproverFieldPage.tsx b/src/pages/workspace/recruiting/approver/MergeATSApproverFieldPage.tsx index c4d3adcc810b..dc4ce3fc0893 100644 --- a/src/pages/workspace/recruiting/approver/MergeATSApproverFieldPage.tsx +++ b/src/pages/workspace/recruiting/approver/MergeATSApproverFieldPage.tsx @@ -1,4 +1,4 @@ -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -76,7 +76,7 @@ function MergeATSApproverFieldPage({ enableEdgeToEdgeBottomSafeAreaPadding testID="MergeATSApproverFieldPage" > - + {translate('workspace.recruiting.approverFieldDescription', providerName)} - ) : undefined - } - titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.WORKSPACE_RECRUITING_MERGE_FINAL_APPROVER.getRoute(policyID))} - /> + Navigation.navigate(ROUTES.WORKSPACE_RECRUITING_MERGE_FINAL_APPROVER.getRoute(policyID)))}> + + + {finalApprover ? ( + <> + {description} + + + ) : ( + {description} + )} + + + + + + ); } diff --git a/src/pages/workspace/recruiting/merge/filters/MergeATSFilterSelectionPage.tsx b/src/pages/workspace/recruiting/merge/filters/MergeATSFilterSelectionPage.tsx index 145383307570..c1ce1d47a8ac 100644 --- a/src/pages/workspace/recruiting/merge/filters/MergeATSFilterSelectionPage.tsx +++ b/src/pages/workspace/recruiting/merge/filters/MergeATSFilterSelectionPage.tsx @@ -1,6 +1,6 @@ import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; @@ -113,7 +113,7 @@ function MergeATSFilterSelectionPage({ shouldEnableMaxHeight testID="MergeATSFilterSelectionPage" > - + {descriptionKey ? translate(descriptionKey) : ''} - + {translate('workspace.recruiting.filters.description', providerName)} - + > + + (); + const [collapsedCategories, setCollapsedCategories] = useState([]); + + const updateSearchValue = (value: string) => { + setSearchValue(value); + + // Expand all sections when the search changes. A collapsed section would otherwise hide the rules that match. + setCollapsedCategories([]); + }; + + const toggleCategory = (category: string) => { + setCollapsedCategories((currentCategories) => + currentCategories.includes(category) ? currentCategories.filter((collapsedCategory) => collapsedCategory !== category) : [...currentCategories, category], + ); + }; const trimmedSearch = searchValue.trim().toLowerCase(); const filteredSuggestions = !trimmedSearch ? data - : data.filter((suggestion) => suggestion.title?.toLowerCase().includes(trimmedSearch) || suggestion.prompt?.toLowerCase().includes(trimmedSearch)); + : data.filter( + (suggestion) => + suggestion.title?.toLowerCase().includes(trimmedSearch) || + suggestion.prompt?.toLowerCase().includes(trimmedSearch) || + !!suggestion.category?.toLowerCase().includes(trimmedSearch), + ); const selectedSuggestion = filteredSuggestions.find((suggestion) => suggestion.id === selectedSuggestionID); const hasNoSuggestions = data.length === 0; @@ -91,6 +110,7 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion } const hasNoFilteredSuggestions = filteredSuggestions.length === 0; + const suggestionSections = groupSuggestedAgentRulesByCategory(filteredSuggestions, localeCompare); const button = (