diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index b3bbf7a537ff..8eb4f6474638 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -9,12 +9,16 @@ outputs: runs: using: composite steps: + - name: Remove E/App version from package-lock.json + shell: bash + run: jq 'del(.version, .packages[""].version)' package-lock.json > normalized-package-lock.json + - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: npm cache-dependency-path: | - package-lock.json + normalized-package-lock.json desktop/package-lock.json - id: cache-node-modules diff --git a/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts b/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts index 96bb17a14354..e18019144e4e 100644 --- a/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts +++ b/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts @@ -18,7 +18,7 @@ function run() { GitHubUtils.octokit.actions.listWorkflowRuns({ owner: CONST.GITHUB_OWNER, repo: CONST.APP_REPO, - workflow_id: 'platformDeploy.yml', + workflow_id: 'deploy.yml', event: 'push', branch: tag, }), diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index 7bdbafc0b722..561cc980a4e5 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -12138,7 +12138,7 @@ function run() { GithubUtils_1.default.octokit.actions.listWorkflowRuns({ owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, - workflow_id: 'platformDeploy.yml', + workflow_id: 'deploy.yml', event: 'push', branch: tag, }), diff --git a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts index da946b78a056..b07d29a587be 100644 --- a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts +++ b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts @@ -1,79 +1,9 @@ import * as core from '@actions/core'; import * as github from '@actions/github'; -import type {RestEndpointMethodTypes} from '@octokit/plugin-rest-endpoint-methods/dist-types/generated/parameters-and-response-types'; import {getJSONInput} from '@github/libs/ActionUtils'; import GithubUtils from '@github/libs/GithubUtils'; import GitUtils from '@github/libs/GitUtils'; -type WorkflowRun = RestEndpointMethodTypes['actions']['listWorkflowRuns']['response']['data']['workflow_runs'][number]; - -const BUILD_AND_DEPLOY_JOB_NAME_PREFIX = 'Build and deploy'; - -/** - * This function checks if a given release is a valid baseTag to get the PR list with `git log baseTag...endTag`. - * - * The rules are: - * - production deploys can only be compared with other production deploys - * - staging deploys can be compared with other staging deploys or production deploys. - * The reason is that the final staging release in each deploy cycle will BECOME a production release. - * For example, imagine a checklist is closed with version 9.0.20-6; that's the most recent staging deploy, but the release for 9.0.20-6 is now finalized, so it looks like a prod deploy. - * When 9.0.21-0 finishes deploying to staging, the most recent prerelease is 9.0.20-5. However, we want 9.0.20-6...9.0.21-0, - * NOT 9.0.20-5...9.0.21-0 (so that the PR CP'd in 9.0.20-6 is not included in the next checklist) - */ -async function isReleaseValidBaseForEnvironment(releaseTag: string, isProductionDeploy: boolean) { - if (!isProductionDeploy) { - return true; - } - const isPrerelease = ( - await GithubUtils.octokit.repos.getReleaseByTag({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - tag: releaseTag, - }) - ).data.prerelease; - return !isPrerelease; -} - -/** - * Was a given platformDeploy workflow run successful on at least one platform? - */ -async function wasDeploySuccessful(runID: number) { - const jobsForWorkflowRun = ( - await GithubUtils.octokit.actions.listJobsForWorkflowRun({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - // eslint-disable-next-line @typescript-eslint/naming-convention - run_id: runID, - filter: 'latest', - }) - ).data.jobs; - return jobsForWorkflowRun.some((job) => job.name.startsWith(BUILD_AND_DEPLOY_JOB_NAME_PREFIX) && job.conclusion === 'success'); -} - -/** - * This function checks if a given deploy workflow is a valid basis for comparison when listing PRs merged between two versions. - * It returns the reason a version should be skipped, or an empty string if the version should not be skipped. - */ -async function shouldSkipVersion(lastSuccessfulDeploy: WorkflowRun, inputTag: string, isProductionDeploy: boolean): Promise<string> { - if (!lastSuccessfulDeploy?.head_branch) { - // This should never happen. Just doing this to appease TS. - return ''; - } - - // we never want to compare a tag with itself. This check is necessary because prod deploys almost always have the same version as the last staging deploy. - // In this case, the next for wrong environment fails because the release that triggered that staging deploy is now finalized, so it looks like a prod deploy. - if (lastSuccessfulDeploy?.head_branch === inputTag) { - return `Same as input tag ${inputTag}`; - } - if (!(await isReleaseValidBaseForEnvironment(lastSuccessfulDeploy?.head_branch, isProductionDeploy))) { - return 'Was a staging deploy, we only want to compare with other production deploys'; - } - if (!(await wasDeploySuccessful(lastSuccessfulDeploy.id))) { - return 'Was an unsuccessful deploy, nothing was deployed in that version'; - } - return ''; -} - async function run() { try { const inputTag = core.getInput('TAG', {required: true}); @@ -82,44 +12,56 @@ async function run() { console.log(`Looking for PRs deployed to ${deployEnv} in ${inputTag}...`); - const completedDeploys = ( - await GithubUtils.octokit.actions.listWorkflowRuns({ + let priorTag: string | undefined; + let foundCurrentRelease = false; + await GithubUtils.paginate( + GithubUtils.octokit.repos.listReleases, + { owner: github.context.repo.owner, repo: github.context.repo.repo, // eslint-disable-next-line @typescript-eslint/naming-convention - workflow_id: 'platformDeploy.yml', - status: 'completed', - }) - ).data.workflow_runs - // Note: we filter out cancelled runs instead of looking only for success runs - // because if a build fails on even one platform, then it will have the status 'failure' - .filter((workflowRun) => workflowRun.conclusion !== 'cancelled'); - - // Find the most recent deploy workflow targeting the correct environment, for which at least one of the build jobs finished successfully - let lastSuccessfulDeploy = completedDeploys.shift(); - - if (!lastSuccessfulDeploy) { - throw new Error('Could not find a prior successful deploy'); - } - - let reason = await shouldSkipVersion(lastSuccessfulDeploy, inputTag, isProductionDeploy); - while (lastSuccessfulDeploy && reason) { - console.log( - `Deploy of tag ${lastSuccessfulDeploy.head_branch} was not valid as a base for comparison, looking at the next one. Reason: ${reason}`, - lastSuccessfulDeploy.html_url, - ); - lastSuccessfulDeploy = completedDeploys.shift(); - - if (!lastSuccessfulDeploy) { - throw new Error('Could not find a prior successful deploy'); - } - - reason = await shouldSkipVersion(lastSuccessfulDeploy, inputTag, isProductionDeploy); + per_page: 100, + }, + ({data}, done) => { + // For production deploys, look only at other production deploys. + // staging deploys can be compared with other staging deploys or production deploys. + // The reason is that the final staging release in each deploy cycle will BECOME a production release + const filteredData = isProductionDeploy ? data.filter((release) => !release.prerelease) : data; + + // Release was in the last page, meaning the previous release is the first item in this page + if (foundCurrentRelease) { + priorTag = data.at(0)?.tag_name; + done(); + return filteredData; + } + + // Search for the index of input tag + const indexOfCurrentRelease = filteredData.findIndex((release) => release.tag_name === inputTag); + + // If it happens to be at the end of this page, then the previous tag will be in the next page. + // Set a flag showing we found it so we grab the first release of the next page + if (indexOfCurrentRelease === filteredData.length - 1) { + foundCurrentRelease = true; + return filteredData; + } + + // If it's anywhere else in this page, the the prior release is the next item in the page + if (indexOfCurrentRelease > 0) { + priorTag = filteredData.at(indexOfCurrentRelease + 1)?.tag_name; + done(); + } + + // Release not in this page (or we're done) + return filteredData; + }, + ); + + if (!priorTag) { + throw new Error('Something went wrong and the prior tag could not be found.'); } - const priorTag = lastSuccessfulDeploy.head_branch; console.log(`Looking for PRs deployed to ${deployEnv} between ${priorTag} and ${inputTag}`); - const prList = await GitUtils.getPullRequestsMergedBetween(priorTag ?? '', inputTag); + const prList = await GitUtils.getPullRequestsMergedBetween(priorTag, inputTag); console.log('Found the pull request list: ', prList); core.setOutput('PR_LIST', prList); } catch (error) { diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index 300cb1edc0ed..0346df720b8e 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -11502,97 +11502,51 @@ const github = __importStar(__nccwpck_require__(5438)); const ActionUtils_1 = __nccwpck_require__(6981); const GithubUtils_1 = __importDefault(__nccwpck_require__(9296)); const GitUtils_1 = __importDefault(__nccwpck_require__(1547)); -const BUILD_AND_DEPLOY_JOB_NAME_PREFIX = 'Build and deploy'; -/** - * This function checks if a given release is a valid baseTag to get the PR list with `git log baseTag...endTag`. - * - * The rules are: - * - production deploys can only be compared with other production deploys - * - staging deploys can be compared with other staging deploys or production deploys. - * The reason is that the final staging release in each deploy cycle will BECOME a production release. - * For example, imagine a checklist is closed with version 9.0.20-6; that's the most recent staging deploy, but the release for 9.0.20-6 is now finalized, so it looks like a prod deploy. - * When 9.0.21-0 finishes deploying to staging, the most recent prerelease is 9.0.20-5. However, we want 9.0.20-6...9.0.21-0, - * NOT 9.0.20-5...9.0.21-0 (so that the PR CP'd in 9.0.20-6 is not included in the next checklist) - */ -async function isReleaseValidBaseForEnvironment(releaseTag, isProductionDeploy) { - if (!isProductionDeploy) { - return true; - } - const isPrerelease = (await GithubUtils_1.default.octokit.repos.getReleaseByTag({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - tag: releaseTag, - })).data.prerelease; - return !isPrerelease; -} -/** - * Was a given platformDeploy workflow run successful on at least one platform? - */ -async function wasDeploySuccessful(runID) { - const jobsForWorkflowRun = (await GithubUtils_1.default.octokit.actions.listJobsForWorkflowRun({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - // eslint-disable-next-line @typescript-eslint/naming-convention - run_id: runID, - filter: 'latest', - })).data.jobs; - return jobsForWorkflowRun.some((job) => job.name.startsWith(BUILD_AND_DEPLOY_JOB_NAME_PREFIX) && job.conclusion === 'success'); -} -/** - * This function checks if a given deploy workflow is a valid basis for comparison when listing PRs merged between two versions. - * It returns the reason a version should be skipped, or an empty string if the version should not be skipped. - */ -async function shouldSkipVersion(lastSuccessfulDeploy, inputTag, isProductionDeploy) { - if (!lastSuccessfulDeploy?.head_branch) { - // This should never happen. Just doing this to appease TS. - return ''; - } - // we never want to compare a tag with itself. This check is necessary because prod deploys almost always have the same version as the last staging deploy. - // In this case, the next for wrong environment fails because the release that triggered that staging deploy is now finalized, so it looks like a prod deploy. - if (lastSuccessfulDeploy?.head_branch === inputTag) { - return `Same as input tag ${inputTag}`; - } - if (!(await isReleaseValidBaseForEnvironment(lastSuccessfulDeploy?.head_branch, isProductionDeploy))) { - return 'Was a staging deploy, we only want to compare with other production deploys'; - } - if (!(await wasDeploySuccessful(lastSuccessfulDeploy.id))) { - return 'Was an unsuccessful deploy, nothing was deployed in that version'; - } - return ''; -} async function run() { try { const inputTag = core.getInput('TAG', { required: true }); const isProductionDeploy = !!(0, ActionUtils_1.getJSONInput)('IS_PRODUCTION_DEPLOY', { required: false }, false); const deployEnv = isProductionDeploy ? 'production' : 'staging'; console.log(`Looking for PRs deployed to ${deployEnv} in ${inputTag}...`); - const completedDeploys = (await GithubUtils_1.default.octokit.actions.listWorkflowRuns({ + let priorTag; + let foundCurrentRelease = false; + await GithubUtils_1.default.paginate(GithubUtils_1.default.octokit.repos.listReleases, { owner: github.context.repo.owner, repo: github.context.repo.repo, // eslint-disable-next-line @typescript-eslint/naming-convention - workflow_id: 'platformDeploy.yml', - status: 'completed', - })).data.workflow_runs - // Note: we filter out cancelled runs instead of looking only for success runs - // because if a build fails on even one platform, then it will have the status 'failure' - .filter((workflowRun) => workflowRun.conclusion !== 'cancelled'); - // Find the most recent deploy workflow targeting the correct environment, for which at least one of the build jobs finished successfully - let lastSuccessfulDeploy = completedDeploys.shift(); - if (!lastSuccessfulDeploy) { - throw new Error('Could not find a prior successful deploy'); - } - let reason = await shouldSkipVersion(lastSuccessfulDeploy, inputTag, isProductionDeploy); - while (lastSuccessfulDeploy && reason) { - console.log(`Deploy of tag ${lastSuccessfulDeploy.head_branch} was not valid as a base for comparison, looking at the next one. Reason: ${reason}`, lastSuccessfulDeploy.html_url); - lastSuccessfulDeploy = completedDeploys.shift(); - if (!lastSuccessfulDeploy) { - throw new Error('Could not find a prior successful deploy'); + per_page: 100, + }, ({ data }, done) => { + // For production deploys, look only at other production deploys. + // staging deploys can be compared with other staging deploys or production deploys. + // The reason is that the final staging release in each deploy cycle will BECOME a production release + const filteredData = isProductionDeploy ? data.filter((release) => !release.prerelease) : data; + // Release was in the last page, meaning the previous release is the first item in this page + if (foundCurrentRelease) { + priorTag = data.at(0)?.tag_name; + done(); + return filteredData; + } + // Search for the index of input tag + const indexOfCurrentRelease = filteredData.findIndex((release) => release.tag_name === inputTag); + // If it happens to be at the end of this page, then the previous tag will be in the next page. + // Set a flag showing we found it so we grab the first release of the next page + if (indexOfCurrentRelease === filteredData.length - 1) { + foundCurrentRelease = true; + return filteredData; } - reason = await shouldSkipVersion(lastSuccessfulDeploy, inputTag, isProductionDeploy); + // If it's anywhere else in this page, the the prior release is the next item in the page + if (indexOfCurrentRelease > 0) { + priorTag = filteredData.at(indexOfCurrentRelease + 1)?.tag_name; + done(); + } + // Release not in this page (or we're done) + return filteredData; + }); + if (!priorTag) { + throw new Error('Something went wrong and the prior tag could not be found.'); } - const priorTag = lastSuccessfulDeploy.head_branch; console.log(`Looking for PRs deployed to ${deployEnv} between ${priorTag} and ${inputTag}`); - const prList = await GitUtils_1.default.getPullRequestsMergedBetween(priorTag ?? '', inputTag); + const prList = await GitUtils_1.default.getPullRequestsMergedBetween(priorTag, inputTag); console.log('Found the pull request list: ', prList); core.setOutput('PR_LIST', prList); } diff --git a/.github/scripts/createHelpRedirects.sh b/.github/scripts/createHelpRedirects.sh index 1425939ff3ec..76696977de4d 100755 --- a/.github/scripts/createHelpRedirects.sh +++ b/.github/scripts/createHelpRedirects.sh @@ -1,7 +1,7 @@ #!/bin/bash # # Adds new routes to the Cloudflare Bulk Redirects list for communityDot to helpDot -# pages. Does some basic sanity checking. +# pages. Sanity checking is done upstream in the PRs themselves in verifyRedirect.sh. set -e diff --git a/.github/scripts/validateActionsAndWorkflows.sh b/.github/scripts/validateActionsAndWorkflows.sh index 07348a302f20..fadb39c88e45 100755 --- a/.github/scripts/validateActionsAndWorkflows.sh +++ b/.github/scripts/validateActionsAndWorkflows.sh @@ -45,7 +45,7 @@ for ((i=0; i < ${#WORKFLOWS[@]}; i++)); do # Skip linting e2e workflow due to bug here: https://github.com/SchemaStore/schemastore/issues/2579 if [[ "$WORKFLOW" == './workflows/e2ePerformanceTests.yml' || "$WORKFLOW" == './workflows/testBuild.yml' - || "$WORKFLOW" == './workflows/platformDeploy.yml' ]]; then + || "$WORKFLOW" == './workflows/deploy.yml' ]]; then continue fi diff --git a/.github/scripts/verifyRedirect.sh b/.github/scripts/verifyRedirect.sh index 05c402ad7766..af9861f40921 100755 --- a/.github/scripts/verifyRedirect.sh +++ b/.github/scripts/verifyRedirect.sh @@ -1,27 +1,31 @@ #!/bin/bash -# HelpDot - Verifies that redirects.csv does not have any duplicates -# Duplicate sourceURLs break redirection on cloudflare pages +# HelpDot - Verifies that redirects.csv does not have any errors that would prevent +# the bulk redirects in Cloudflare from working. This includes: +# Duplicate sourceURLs +# Source URLs containing anchors or URL params +# URLs pointing to themselves +# +# We also prevent adding source or destination URLs outside of an allowed list +# of domains. That's because these redirects run on our zone as a whole, so you +# could add a redirect for sites outside of help/community and Cloudflare would allow it +# and it would work. source scripts/shellUtils.sh declare -r REDIRECTS_FILE="docs/redirects.csv" declare -a ITEMS_TO_ADD -declare -r RED='\033[0;31m' -declare -r GREEN='\033[0;32m' -declare -r NC='\033[0m' - duplicates=$(awk -F, 'a[$1]++{print $1}' $REDIRECTS_FILE) if [[ -n "$duplicates" ]]; then - echo "${RED}duplicate redirects are not allowed: $duplicates ${NC}" + echo "${RED}duplicate redirects are not allowed: $duplicates ${RESET}" exit 1 fi npm run detectRedirectCycle DETECT_CYCLE_EXIT_CODE=$? if [[ DETECT_CYCLE_EXIT_CODE -eq 1 ]]; then - echo -e "${RED}The redirects.csv has a cycle. Please remove the redirect cycle because it will cause an infinite redirect loop ${NC}" + echo -e "${RED}The redirects.csv has a cycle. Please remove the redirect cycle because it will cause an infinite redirect loop ${RESET}" exit 1 fi @@ -46,8 +50,8 @@ while read -r line; do # Basic sanity checking to make sure that the source and destination are in expected # subdomains. - if ! [[ $SOURCE_URL =~ ^https://(community|help)\.expensify\.com ]] || [[ $SOURCE_URL =~ \# ]]; then - error "Found source URL that is not a communityDot or helpDot URL, or contains a '#': $SOURCE_URL" + if ! [[ $SOURCE_URL =~ ^https://(community|help)\.expensify\.com ]] || [[ $SOURCE_URL =~ (\#|\?) ]]; then + error "Found source URL that is not a communityDot or helpDot URL, or contains a '#' or '?': $SOURCE_URL" exit 1 fi @@ -66,9 +70,9 @@ done <<< "$(tail +2 $REDIRECTS_FILE)" # Sanity check that we should actually be running this and we aren't about to delete # every single redirect. if [[ "${#ITEMS_TO_ADD[@]}" -lt 1 ]]; then - error "No items found to add, why are we running?" + error "${RED}No items found to add, why are we running?${RESET}" exit 1 fi -echo -e "${GREEN}The redirects.csv is valid!${NC}" +echo -e "${GREEN}The redirects.csv is valid!${RESET}" exit 0 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6b1b72f1f901..536fef1bf220 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,15 +4,41 @@ on: push: branches: [staging, production] +env: + SHOULD_DEPLOY_PRODUCTION: ${{ github.ref == 'refs/heads/production' }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - deployStaging: + validateActor: + runs-on: ubuntu-latest + timeout-minutes: 90 + outputs: + IS_DEPLOYER: ${{ fromJSON(steps.isUserDeployer.outputs.IS_DEPLOYER) || github.actor == 'OSBotify' || github.actor == 'os-botify[bot]' }} + steps: + - name: Check if user is deployer + id: isUserDeployer + run: | + if gh api /orgs/Expensify/teams/mobile-deployers/memberships/${{ github.actor }} --silent; then + echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT" + else + echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT" + fi + env: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + + prep: + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} runs-on: ubuntu-latest - if: github.ref == 'refs/heads/staging' + outputs: + APP_VERSION: ${{ steps.getAppVersion.outputs.VERSION }} steps: - - name: Checkout staging branch + - name: Checkout uses: actions/checkout@v4 with: - ref: staging token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Setup git for OSBotify @@ -23,13 +49,476 @@ jobs: OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} - - name: Get current app version - run: echo "STAGING_VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + - name: Get app version + id: getAppVersion + run: echo "VERSION=$(jq -r .version < package.json)" >> "$GITHUB_OUTPUT" + + - name: Create and push tag + if: ${{ github.ref == 'refs/heads/staging' }} + run: | + git tag ${{ steps.getAppVersion.outputs.VERSION }} + git push origin --tags + + # Note: we're updating the checklist before running the deploys and assuming that it will succeed on at least one platform + deployChecklist: + name: Create or update deploy checklist + uses: ./.github/workflows/createDeployChecklist.yml + if: ${{ github.ref == 'refs/heads/staging' }} + needs: prep + secrets: inherit + + android: + name: Build and deploy Android + needs: prep + runs-on: ubuntu-latest-xl + env: + RUBYOPT: '-rostruct' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'oracle' + java-version: '17' + + - name: Setup Ruby + uses: ruby/setup-ruby@v1.190.0 + with: + bundler-cache: true + + - name: Decrypt keystore + run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output my-upload-key.keystore my-upload-key.keystore.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt json key + run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Get Android native version + id: getAndroidVersion + run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_OUTPUT" + + - name: Build Android app + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: bundle exec fastlane android build + env: + MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} + MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} + + - name: Upload Android app to Google Play + run: bundle exec fastlane android ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'upload_google_play_production' || 'upload_google_play_internal' }} + env: + VERSION: ${{ steps.getAndroidVersion.outputs.VERSION_CODE }} + + - name: Upload Android build to Browser Stack + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@./android/app/build/outputs/bundle/productionRelease/app-production-release.aab" + env: + BROWSERSTACK: ${{ secrets.BROWSERSTACK }} + + - name: Upload Android sourcemaps artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: android-sourcemaps-artifact + path: ./android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map + + - name: Upload Android build artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: android-build-artifact + path: ./android/app/build/outputs/bundle/productionRelease/app-production-release.aab + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Warn deployers if Android production deploy failed + if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: "#DB4545", + pretext: `<!subteam^S4TJJ3PSL>`, + text: `π₯ Android production deploy failed. Please manually submit ${{ needs.prep.outputs.APP_VERSION }} in the <https://play.google.com/console/u/0/developers/8765590895836334604/app/4973041797096886180/releases/overview|Google Play Store>. π₯`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + desktop: + name: Build and deploy Desktop + needs: prep + runs-on: macos-14-large + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Decrypt Developer ID Certificate + run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg + env: + DEVELOPER_ID_SECRET_PASSPHRASE: ${{ secrets.DEVELOPER_ID_SECRET_PASSPHRASE }} + + - name: Build desktop app + run: | + if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then + npm run desktop-build + else + npm run desktop-build-staging + fi + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_PRODUCTION }} + + - name: Upload desktop sourcemaps artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.SHOULD_DEPLOY_PRODUCTION && 'desktop-sourcemaps-artifact' || 'desktop-staging-sourcemaps-artifact' }} + path: ./desktop/dist/www/merged-source-map.js.map + + - name: Upload desktop build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.SHOULD_DEPLOY_PRODUCTION && 'desktop-build-artifact' || 'desktop-staging-build-artifact' }} + path: ./desktop-build/NewExpensify.dmg + + iOS: + name: Build and deploy iOS + needs: prep + env: + DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer + runs-on: macos-13-xlarge + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + + - name: Setup Node + id: setup-node + uses: ./.github/actions/composite/setupNode + + - name: Setup Ruby + uses: ruby/setup-ruby@v1.190.0 + with: + bundler-cache: true + + - name: Cache Pod dependencies + uses: actions/cache@v4 + id: pods-cache + with: + path: ios/Pods + key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }} + + - name: Compare Podfile.lock and Manifest.lock + id: compare-podfile-and-manifest + run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" + + - name: Install cocoapods + uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 + if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' + with: + timeout_minutes: 10 + max_attempts: 5 + command: scripts/pod-install.sh + + - name: Decrypt AppStore profile + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt AppStore Notification Service profile + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore_Notification_Service.mobileprovision NewApp_AppStore_Notification_Service.mobileprovision.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt certificate + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt App Store Connect API key + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output ios-fastlane-json-key.json ios-fastlane-json-key.json.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Get iOS native version + id: getIOSVersion + run: echo "IOS_VERSION=$(echo '${{ needs.prep.outputs.APP_VERSION }}' | tr '-' '.')" >> "$GITHUB_OUTPUT" + + - name: Build iOS release app + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: bundle exec fastlane ios build - - name: π Create prerelease to trigger staging deploy π - run: gh release create ${{ env.STAGING_VERSION }} --title ${{ env.STAGING_VERSION }} --generate-notes --prerelease --target staging + - name: Upload release build to TestFlight + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: bundle exec fastlane ios upload_testflight env: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + APPLE_CONTACT_EMAIL: ${{ secrets.APPLE_CONTACT_EMAIL }} + APPLE_CONTACT_PHONE: ${{ secrets.APPLE_CONTACT_PHONE }} + APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }} + APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} + + - name: Submit build for App Store review + if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: bundle exec fastlane ios submit_for_review + env: + VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} + + - name: Upload iOS build to Browser Stack + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/Users/runner/work/App/App/New Expensify.ipa" + env: + BROWSERSTACK: ${{ secrets.BROWSERSTACK }} + + - name: Upload iOS sourcemaps artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: ios-sourcemaps-artifact + path: ./main.jsbundle.map + + - name: Upload iOS build artifact + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: actions/upload-artifact@v4 + with: + name: ios-build-artifact + path: /Users/runner/work/App/App/New\ Expensify.ipa + + - name: Warn deployers if iOS production deploy failed + if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: "#DB4545", + pretext: `<!subteam^S4TJJ3PSL>`, + text: `π₯ iOS production deploy failed. Please manually submit ${{ steps.getIOSVersion.outputs.IOS_VERSION }} in the <https://appstoreconnect.apple.com/apps/1530278510/appstore|App Store>. π₯`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + web: + name: Build and deploy Web + needs: prep + runs-on: ubuntu-latest-xl + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Setup Cloudflare CLI + run: pip3 install cloudflare==2.19.0 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Build web + run: | + if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then + npm run build + else + npm run build-staging + fi + + - name: Build storybook docs + continue-on-error: true + run: | + if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then + npm run storybook-build + else + npm run storybook-build-staging + fi + + - name: Deploy to S3 + run: | + aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist ${{ env.S3_URL }}/ + aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{ env.S3_URL }}/.well-known/apple-app-site-association + aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{env.S3_URL }}/apple-app-site-association + env: + S3_URL: s3://${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging-' || '' }}expensify-cash + + - name: Purge Cloudflare cache + run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging.' || '' }}new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache + env: + CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} + + - name: Set current App version in Env + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Verify staging deploy + if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: | + sleep 5 + DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://staging.new.expensify.com/version.json | jq -r '.version')" + if [[ '${{ needs.prep.outputs.APP_VERSION }}' != "$DOWNLOADED_VERSION" ]]; then + echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ needs.prep.outputs.APP_VERSION }}. Something went wrong..." + exit 1 + fi + + - name: Verify production deploy + if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + run: | + sleep 5 + DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://new.expensify.com/version.json | jq -r '.version')" + if [[ '${{ needs.prep.outputs.APP_VERSION }}' != "$DOWNLOADED_VERSION" ]]; then + echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ needs.prep.outputs.APP_VERSION }}. Something went wrong..." + exit 1 + fi + + - name: Upload web sourcemaps artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.SHOULD_DEPLOY_PRODUCTION && 'web' || 'web-staging' }}-sourcemaps-artifact + path: ./dist/merged-source-map.js.map + + - name: Compress web build .tar.gz and .zip + run: | + tar -czvf webBuild.tar.gz dist + zip -r webBuild.zip dist + + - name: Upload .tar.gz web build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.SHOULD_DEPLOY_PRODUCTION && 'web' || 'web-staging' }}-build-tar-gz-artifact + path: ./webBuild.tar.gz + + - name: Upload .zip web build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.SHOULD_DEPLOY_PRODUCTION && 'web' || 'web-staging' }}-build-zip-artifact + path: ./webBuild.zip + + postSlackMessageOnFailure: + name: Post a Slack message when any platform fails to build or deploy + runs-on: ubuntu-latest + if: ${{ failure() }} + needs: [android, desktop, iOS, web] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Post Slack message on failure + uses: ./.github/actions/composite/announceFailedWorkflowInSlack + with: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + + # Build a version of iOS and Android HybridApp if we are deploying to staging + hybridApp: + runs-on: ubuntu-latest + needs: prep + if: ${{ github.ref == 'refs/heads/staging' }} + steps: + - name: 'Deploy HybridApp' + run: gh workflow run --repo Expensify/Mobile-Deploy deploy.yml -f force_build=true -f build_version="${{ needs.prep.outputs.APP_VERSION }}" + env: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + + checkDeploymentSuccess: + runs-on: ubuntu-latest + outputs: + IS_AT_LEAST_ONE_PLATFORM_DEPLOYED: ${{ steps.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED }} + IS_ALL_PLATFORMS_DEPLOYED: ${{ steps.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED }} + needs: [android, desktop, iOS, web] + if: ${{ always() }} + steps: + - name: Check deployment success on at least one platform + id: checkDeploymentSuccess + run: | + isAtLeastOnePlatformDeployed="false" + isAllPlatformsDeployed="false" + if [ "${{ needs.android.result }}" == "success" ] || \ + [ "${{ needs.iOS.result }}" == "success" ] || \ + [ "${{ needs.desktop.result }}" == "success" ] || \ + [ "${{ needs.web.result }}" == "success" ]; then + isAtLeastOnePlatformDeployed="true" + fi + if [ "${{ needs.android.result }}" == "success" ] && \ + [ "${{ needs.iOS.result }}" == "success" ] && \ + [ "${{ needs.desktop.result }}" == "success" ] && \ + [ "${{ needs.web.result }}" == "success" ]; then + isAllPlatformsDeployed="true" + fi + echo "IS_AT_LEAST_ONE_PLATFORM_DEPLOYED=\"$isAtLeastOnePlatformDeployed\"" >> "$GITHUB_OUTPUT" + echo "IS_ALL_PLATFORMS_DEPLOYED=\"$isAllPlatformsDeployed\"" >> "$GITHUB_OUTPUT" + + createPrerelease: + runs-on: ubuntu-latest + if: ${{ github.ref == 'refs/heads/staging' && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} + needs: [prep, checkDeploymentSuccess] + steps: + - name: Download all workflow run artifacts + uses: actions/download-artifact@v4 + + - name: π Create prerelease π + run: | + gh release create ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --title ${{ needs.prep.outputs.APP_VERSION }} --generate-notes --prerelease --target staging + RETRIES=0 + MAX_RETRIES=10 + until [[ $(gh release view ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }}) || $RETRIES -ge $MAX_RETRIES ]]; do + echo "release not found, retrying $((MAX_RETRIES - RETRIES++)) times" + sleep 1 + done + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Rename web and desktop sourcemaps artifacts before assets upload in order to have unique ReleaseAsset.name + run: | + mv ./desktop-staging-sourcemaps-artifact/merged-source-map.js.map ./desktop-staging-sourcemaps-artifact/desktop-staging-merged-source-map.js.map + mv ./web-staging-sourcemaps-artifact/merged-source-map.js.map ./web-staging-sourcemaps-artifact/web-staging-merged-source-map.js.map + + - name: Upload artifacts to GitHub Release + run: | + gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber \ + ./android-sourcemaps-artifact/index.android.bundle.map#android-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ + ./android-build-artifact/app-production-release.aab \ + ./desktop-staging-sourcemaps-artifact/desktop-staging-merged-source-map.js.map#desktop-staging-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ + ./desktop-staging-build-artifact/NewExpensify.dmg#NewExpensifyStaging.dmg \ + ./ios-sourcemaps-artifact/main.jsbundle.map#ios-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ + ./ios-build-artifact/New\ Expensify.ipa \ + ./web-staging-sourcemaps-artifact/web-staging-merged-source-map.js.map#web-staging-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ + ./web-staging-build-tar-gz-artifact/webBuild.tar.gz#stagingWebBuild.tar.gz \ + ./web-staging-build-zip-artifact/webBuild.zip#stagingWebBuild.zip + env: + GITHUB_TOKEN: ${{ github.token }} - name: Warn deployers if staging deploy failed if: ${{ failure() }} @@ -49,34 +538,37 @@ jobs: GITHUB_TOKEN: ${{ github.token }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - deployProduction: + finalizeRelease: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/production' + if: ${{ github.ref == 'refs/heads/production' && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} + needs: [prep, checkDeploymentSuccess] steps: - - uses: actions/checkout@v4 - name: Checkout - with: - ref: production - token: ${{ secrets.OS_BOTIFY_TOKEN }} + - name: Download all workflow run artifacts + uses: actions/download-artifact@v4 - - name: Setup git for OSBotify - uses: ./.github/actions/composite/setupGitForOSBotifyApp - id: setupGitForOSBotify - with: - GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} - OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + - name: Rename web and desktop sourcemaps artifacts before assets upload in order to have unique ReleaseAsset.name + run: | + mv ./desktop-sourcemaps-artifact/merged-source-map.js.map ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map + mv ./web-sourcemaps-artifact/merged-source-map.js.map ./web-sourcemaps-artifact/web-merged-source-map.js.map - - name: Get current app version - run: echo "PRODUCTION_VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + - name: π Edit the release to be no longer a prerelease π + run: | + LATEST_RELEASE="$(gh release list --repo ${{ github.repository }} --exclude-pre-releases --json tagName,isLatest --jq '.[] | select(.isLatest) | .tagName')" + gh api --method POST /repos/Expensify/App/releases/generate-notes -f "tag_name=${{ needs.prep.outputs.APP_VERSION }}" -f "previous_tag_name=$LATEST_RELEASE" | jq -r '.body' >> releaseNotes.md + gh release edit ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --prerelease=false --latest --notes-file releaseNotes.md + env: + GITHUB_TOKEN: ${{ github.token }} - - name: π Edit the release to be no longer a prerelease to deploy production π + - name: Upload artifacts to GitHub Release run: | - LATEST_RELEASE="$(gh release list --exclude-pre-releases --json tagName,isLatest --jq '.[] | select(.isLatest) | .tagName')" - gh api --method POST /repos/Expensify/App/releases/generate-notes -f "tag_name=${{ env.PRODUCTION_VERSION }}" -f "previous_tag_name=$LATEST_RELEASE" >> releaseNotes.md - gh release edit ${{ env.PRODUCTION_VERSION }} --prerelease=false --latest --notes-file releaseNotes.md + gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber \ + ./desktop-sourcemaps-artifact/desktop-merged-source-map.js.map#desktop-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ + ./desktop-build-artifact/NewExpensify.dmg \ + ./web-sourcemaps-artifact/web-merged-source-map.js.map#web-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ + ./web-build-tar-gz-artifact/webBuild.tar.gz \ + ./web-build-zip-artifact/webBuild.zip env: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} - name: Warn deployers if production deploy failed if: ${{ failure() }} @@ -95,3 +587,90 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + postSlackMessageOnSuccess: + name: Post a Slack message when all platforms deploy successfully + runs-on: ubuntu-latest + if: ${{ fromJSON(needs.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED) }} + needs: [prep, checkDeploymentSuccess, createPrerelease, finalizeRelease] + steps: + - name: 'Announces the deploy in the #announce Slack room' + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#announce', + attachments: [{ + color: 'good', + text: `ποΈ Successfully deployed ${process.env.AS_REPO} <https://github.com/Expensify/App/releases/tag/${{ needs.prep.outputs.APP_VERSION }}|${{ needs.prep.outputs.APP_VERSION }}> to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} ποΈ`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + - name: 'Announces the deploy in the #deployer Slack room' + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: 'good', + text: `ποΈ Successfully deployed ${process.env.AS_REPO} <https://github.com/Expensify/App/releases/tag/${{ needs.prep.outputs.APP_VERSION }}|${{ needs.prep.outputs.APP_VERSION }}> to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} ποΈ`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + - name: 'Announces a production deploy in the #expensify-open-source Slack room' + uses: 8398a7/action-slack@v3 + if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + with: + status: custom + custom_payload: | + { + channel: '#expensify-open-source', + attachments: [{ + color: 'good', + text: `ποΈ Successfully deployed ${process.env.AS_REPO} <https://github.com/Expensify/App/releases/tag/${{ needs.prep.outputs.APP_VERSION }}|${{ needs.prep.outputs.APP_VERSION }}> to production ποΈ`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + postGithubComment: + name: Post a GitHub comments on all deployed PRs when platforms are done building and deploying + runs-on: ubuntu-latest + if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} + needs: [prep, android, desktop, iOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Get Release Pull Request List + id: getReleasePRList + uses: ./.github/actions/javascript/getDeployPullRequestList + with: + TAG: ${{ needs.prep.outputs.APP_VERSION }} + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + + - name: Comment on issues + uses: ./.github/actions/javascript/markPullRequestsAsDeployed + with: + PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} + IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + DEPLOY_VERSION: ${{ needs.prep.outputs.APP_VERSION }} + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + ANDROID: ${{ needs.android.result }} + DESKTOP: ${{ needs.desktop.result }} + IOS: ${{ needs.iOS.result }} + WEB: ${{ needs.web.result }} diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index e57556143978..b9352d406feb 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -220,7 +220,7 @@ jobs: Test spec output.txt log_artifacts: debug.log cleanup: true - timeout: 5400 + timeout: 7200 - name: Print logs if run failed if: failure() diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml deleted file mode 100644 index bacab79998f9..000000000000 --- a/.github/workflows/platformDeploy.yml +++ /dev/null @@ -1,487 +0,0 @@ -name: Build and deploy android, desktop, iOS, and web clients - -# This workflow is run when a release or prerelease is created -on: - release: - types: [prereleased, released] - -env: - SHOULD_DEPLOY_PRODUCTION: ${{ github.event.action == 'released' }} - -concurrency: - group: ${{ github.workflow }}-${{ github.event.action }} - cancel-in-progress: true - -jobs: - validateActor: - runs-on: ubuntu-latest - outputs: - IS_DEPLOYER: ${{ fromJSON(steps.isUserDeployer.outputs.IS_DEPLOYER) || github.actor == 'OSBotify' || github.actor == 'os-botify[bot]' }} - steps: - - name: Check if user is deployer - id: isUserDeployer - run: | - if gh api /orgs/Expensify/teams/mobile-deployers/memberships/${{ github.actor }} --silent; then - echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT" - else - echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT" - fi - env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - - # Note: we're updating the checklist before running the deploys and assuming that it will succeed on at least one platform - deployChecklist: - name: Create or update deploy checklist - uses: ./.github/workflows/createDeployChecklist.yml - if: ${{ github.event.action != 'released' }} - needs: validateActor - secrets: inherit - - android: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy Android - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - runs-on: ubuntu-latest-xl - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Configure MapBox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'oracle' - java-version: '17' - - - name: Setup Ruby - uses: ruby/setup-ruby@v1.190.0 - with: - bundler-cache: true - - - name: Decrypt keystore - run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output my-upload-key.keystore my-upload-key.keystore.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt json key - run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Set version in ENV - run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_ENV" - - - name: Run Fastlane - run: bundle exec fastlane android ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} - env: - RUBYOPT: '-rostruct' - MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} - MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} - VERSION: ${{ env.VERSION_CODE }} - - - name: Upload Android build to Browser Stack - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@./android/app/build/outputs/bundle/productionRelease/app-production-release.aab" - env: - BROWSERSTACK: ${{ secrets.BROWSERSTACK }} - - - name: Upload Android sourcemaps to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map#android-sourcemap-${{ github.event.release.tag_name }} - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload Android build to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} android/app/build/outputs/bundle/productionRelease/app-production-release.aab - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Warn deployers if Android production deploy failed - if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: "#DB4545", - pretext: `<!subteam^S4TJJ3PSL>`, - text: `π₯ Android production deploy failed. Please manually submit ${{ github.event.release.tag_name }} in the <https://play.google.com/console/u/0/developers/8765590895836334604/app/4973041797096886180/releases/overview|Google Play Store>. π₯`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - desktop: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy Desktop - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - runs-on: macos-14-large - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Decrypt Developer ID Certificate - run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg - env: - DEVELOPER_ID_SECRET_PASSPHRASE: ${{ secrets.DEVELOPER_ID_SECRET_PASSPHRASE }} - - - name: Build desktop app - run: | - if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then - npm run desktop-build - else - npm run desktop-build-staging - fi - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_PRODUCTION }} - - - name: Upload desktop sourcemaps to GitHub Release - run: gh release upload ${{ github.event.release.tag_name }} desktop/dist/www/merged-source-map.js.map#desktop-sourcemap-${{ github.event.release.tag_name }} --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload desktop build to GitHub Release - run: gh release upload ${{ github.event.release.tag_name }} desktop-build/NewExpensify.dmg --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - iOS: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy iOS - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - env: - DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer - runs-on: macos-13-xlarge - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Configure MapBox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - - name: Setup Node - id: setup-node - uses: ./.github/actions/composite/setupNode - - - name: Setup Ruby - uses: ruby/setup-ruby@v1.190.0 - with: - bundler-cache: true - - - name: Cache Pod dependencies - uses: actions/cache@v4 - id: pods-cache - with: - path: ios/Pods - key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }} - - - name: Compare Podfile.lock and Manifest.lock - id: compare-podfile-and-manifest - run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" - - - name: Install cocoapods - uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 - if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' - with: - timeout_minutes: 10 - max_attempts: 5 - command: scripts/pod-install.sh - - - name: Decrypt AppStore profile - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt AppStore Notification Service profile - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore_Notification_Service.mobileprovision NewApp_AppStore_Notification_Service.mobileprovision.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt certificate - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt App Store Connect API key - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output ios-fastlane-json-key.json ios-fastlane-json-key.json.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Set iOS version in ENV - run: echo "IOS_VERSION=$(echo '${{ github.event.release.tag_name }}' | tr '-' '.')" >> "$GITHUB_ENV" - - - name: Run Fastlane - run: bundle exec fastlane ios ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} - env: - APPLE_CONTACT_EMAIL: ${{ secrets.APPLE_CONTACT_EMAIL }} - APPLE_CONTACT_PHONE: ${{ secrets.APPLE_CONTACT_PHONE }} - APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }} - APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} - VERSION: ${{ env.IOS_VERSION }} - - - name: Upload iOS build to Browser Stack - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/Users/runner/work/App/App/New Expensify.ipa" - env: - BROWSERSTACK: ${{ secrets.BROWSERSTACK }} - - - name: Upload iOS sourcemaps to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} main.jsbundle.map#ios-sourcemap-${{ github.event.release.tag_name }} - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload iOS build to GitHub Release - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: gh release upload ${{ github.event.release.tag_name }} /Users/runner/work/App/App/New\ Expensify.ipa - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Warn deployers if iOS production deploy failed - if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: "#DB4545", - pretext: `<!subteam^S4TJJ3PSL>`, - text: `π₯ iOS production deploy failed. Please manually submit ${{ env.IOS_VERSION }} in the <https://appstoreconnect.apple.com/apps/1530278510/appstore|App Store>. π₯`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - web: - # WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly - name: Build and deploy Web - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} - runs-on: ubuntu-latest-xl - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Setup Cloudflare CLI - run: pip3 install cloudflare==2.19.0 - - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-1 - - - name: Build web - run: | - if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then - npm run build - else - npm run build-staging - fi - - - name: Build storybook docs - continue-on-error: true - run: | - if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then - npm run storybook-build - else - npm run storybook-build-staging - fi - - - name: Deploy to S3 - run: | - aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist ${{ env.S3_URL }}/ - aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{ env.S3_URL }}/.well-known/apple-app-site-association - aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{env.S3_URL }}/apple-app-site-association - env: - S3_URL: s3://${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging-' || '' }}expensify-cash - - - name: Purge Cloudflare cache - run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging.' || '' }}new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache - env: - CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} - - - name: Verify staging deploy - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: | - sleep 5 - DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://staging.new.expensify.com/version.json | jq -r '.version')" - if [[ '${{ github.event.release.tag_name }}' != "$DOWNLOADED_VERSION" ]]; then - echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ github.event.release.tag_name }}. Something went wrong..." - exit 1 - fi - - - name: Verify production deploy - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: | - sleep 5 - DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://new.expensify.com/version.json | jq -r '.version')" - if [[ '${{ github.event.release.tag_name }}' != "$DOWNLOADED_VERSION" ]]; then - echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ github.event.release.tag_name }}. Something went wrong..." - exit 1 - fi - - - name: Upload web sourcemaps to GitHub Release - run: gh release upload ${{ github.event.release.tag_name }} dist/merged-source-map.js.map#web-sourcemap-${{ github.event.release.tag_name }} --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload web build to GitHub Release - run: | - tar -czvf webBuild.tar.gz dist - zip -r webBuild.zip dist - gh release upload ${{ github.event.release.tag_name }} webBuild.tar.gz webBuild.zip --clobber - env: - GITHUB_TOKEN: ${{ github.token }} - - postSlackMessageOnFailure: - name: Post a Slack message when any platform fails to build or deploy - runs-on: ubuntu-latest - if: ${{ failure() }} - needs: [android, desktop, iOS, web] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Post Slack message on failure - uses: ./.github/actions/composite/announceFailedWorkflowInSlack - with: - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} - - # Build a version of iOS and Android HybridApp if we are deploying to staging - hybridApp: - runs-on: ubuntu-latest - needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) && github.event.action != 'released' }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: 'Deploy HybridApp' - run: gh workflow run --repo Expensify/Mobile-Deploy deploy.yml -f force_build=true -f build_version="$(npm run print-version --silent)" - env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - - postSlackMessageOnSuccess: - name: Post a Slack message when all platforms deploy successfully - runs-on: ubuntu-latest - if: ${{ success() }} - needs: [android, desktop, iOS, web] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set version - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - - name: 'Announces the deploy in the #announce Slack room' - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#announce', - attachments: [{ - color: 'good', - text: `ποΈ Successfully deployed ${process.env.AS_REPO} <https://github.com/Expensify/App/releases/tag/${{ env.VERSION }}|${{ env.VERSION }}> to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} ποΈ`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - - name: 'Announces the deploy in the #deployer Slack room' - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: 'good', - text: `ποΈ Successfully deployed ${process.env.AS_REPO} <https://github.com/Expensify/App/releases/tag/${{ env.VERSION }}|${{ env.VERSION }}> to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} ποΈ`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - - name: 'Announces a production deploy in the #expensify-open-source Slack room' - uses: 8398a7/action-slack@v3 - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - with: - status: custom - custom_payload: | - { - channel: '#expensify-open-source', - attachments: [{ - color: 'good', - text: `ποΈ Successfully deployed ${process.env.AS_REPO} <https://github.com/Expensify/App/releases/tag/${{ env.VERSION }}|${{ env.VERSION }}> to production ποΈ`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - postGithubComment: - name: Post a GitHub comment when platforms are done building and deploying - runs-on: ubuntu-latest - if: ${{ !cancelled() }} - needs: [android, desktop, iOS, web] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Set version - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - - name: Get Release Pull Request List - id: getReleasePRList - uses: ./.github/actions/javascript/getDeployPullRequestList - with: - TAG: ${{ env.VERSION }} - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - - - name: Comment on issues - uses: ./.github/actions/javascript/markPullRequestsAsDeployed - with: - PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} - IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - DEPLOY_VERSION: ${{ env.VERSION }} - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - ANDROID: ${{ needs.android.result }} - DESKTOP: ${{ needs.desktop.result }} - IOS: ${{ needs.iOS.result }} - WEB: ${{ needs.web.result }} diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 21f7fcedfe85..f523faf785c0 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -10,6 +10,9 @@ on: types: [opened, synchronize, labeled] branches: ['*ci-test/**'] +env: + PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} + jobs: validateActor: runs-on: ubuntu-latest @@ -35,7 +38,6 @@ jobs: echo "The 'Ready to Build' label is not attached to the PR #${{ env.PULL_REQUEST_NUMBER }}" fi env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} GITHUB_TOKEN: ${{ github.token }} getBranchRef: @@ -64,7 +66,7 @@ jobs: if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} runs-on: ubuntu-latest-xl env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} + RUBYOPT: '-rostruct' steps: - name: Checkout uses: actions/checkout@v4 @@ -111,17 +113,19 @@ jobs: - name: Configure MapBox SDK run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - name: Run Fastlane beta test - id: runFastlaneBetaTest - run: bundle exec fastlane android build_internal + - name: Run AdHoc build + run: bundle exec fastlane android build_adhoc + env: + MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} + MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} + + - name: Upload AdHoc build to S3 + run: bundle exec fastlane android upload_s3 env: - RUBYOPT: '-rostruct' S3_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} S3_BUCKET: ad-hoc-expensify-cash S3_REGION: us-east-1 - MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} - MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} - name: Upload Artifact uses: actions/upload-artifact@v4 @@ -134,7 +138,6 @@ jobs: needs: [validateActor, getBranchRef] if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer runs-on: macos-13-xlarge steps: @@ -205,8 +208,11 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1 - - name: Run Fastlane - run: bundle exec fastlane ios build_internal + - name: Build AdHoc app + run: bundle exec fastlane ios build_adhoc + + - name: Upload AdHoc build to S3 + run: bundle exec fastlane ios upload_s3 env: S3_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -223,8 +229,6 @@ jobs: name: Build and deploy Desktop for testing needs: [validateActor, getBranchRef] if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} - env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} runs-on: macos-14-large steps: - name: Checkout @@ -268,8 +272,6 @@ jobs: name: Build and deploy Web needs: [validateActor, getBranchRef] if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} - env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} runs-on: ubuntu-latest-xl steps: - name: Checkout @@ -304,8 +306,6 @@ jobs: name: Post a GitHub comment with app download links for testing needs: [validateActor, getBranchRef, android, iOS, desktop, web] if: ${{ always() }} - env: - PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} steps: - name: Checkout uses: actions/checkout@v4 diff --git a/android/app/build.gradle b/android/app/build.gradle index 4b1fb7ae40cf..da063dfc4990 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009002906 - versionName "9.0.29-6" + versionCode 1009003300 + versionName "9.0.33-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/emojis/common.ts b/assets/emojis/common.ts index 5162a71367b2..1039249f0ac8 100644 --- a/assets/emojis/common.ts +++ b/assets/emojis/common.ts @@ -774,11 +774,6 @@ const emojis: PickerEmojis = [ code: 'π€', types: ['π€πΏ', 'π€πΎ', 'π€π½', 'π€πΌ', 'π€π»'], }, - { - name: 'hand_with_index_finger_and_thumb_crossed', - code: 'π«°', - types: ['π«°πΏ', 'π«°πΎ', 'π«°π½', 'π«°πΌ', 'π«°π»'], - }, { name: 'love_you_gesture', code: 'π€', @@ -844,6 +839,11 @@ const emojis: PickerEmojis = [ code: 'π', types: ['ππΏ', 'ππΎ', 'ππ½', 'ππΌ', 'ππ»'], }, + { + name: 'hand_with_index_finger_and_thumb_crossed', + code: 'π«°', + types: ['π«°πΏ', 'π«°πΎ', 'π«°π½', 'π«°πΌ', 'π«°π»'], + }, { name: 'fist_raised', code: 'β', diff --git a/assets/images/Star.svg b/assets/images/Star.svg new file mode 100644 index 000000000000..71fdfde500a0 --- /dev/null +++ b/assets/images/Star.svg @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 20 20"> + <!-- Generator: Adobe Illustrator 28.7.1, SVG Export Plug-In . SVG Version: 1.2.0 Build 142) --> + <g> + <g id="Layer_1"> + <path d="M11.458,2.942l1.214,2.56c.236.499.693.845,1.222.924l2.714.411c1.332.201,1.864,1.905.899,2.883l-1.965,1.993c-.384.387-.556.948-.467,1.496l.464,2.813c.226,1.379-1.164,2.432-2.355,1.781l-2.429-1.328c-.473-.259-1.039-.259-1.512,0l-2.429,1.328c-1.191.651-2.584-.401-2.355-1.781l.464-2.813c.09-.549-.085-1.106-.467-1.496l-1.965-1.993c-.964-.978-.433-2.682.899-2.883l2.714-.411c.529-.079.987-.425,1.222-.924l1.213-2.56c.597-1.256,2.317-1.256,2.911,0h.005Z"/> + </g> + </g> +</svg> \ No newline at end of file diff --git a/assets/images/companyCards/card-amex-blue.svg b/assets/images/companyCards/card-amex-blue.svg new file mode 100644 index 000000000000..5282ca095760 --- /dev/null +++ b/assets/images/companyCards/card-amex-blue.svg @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 235 148"> + <defs> + <style> + .cls-1 { + fill: #0185ff; + } + + .cls-1, .cls-2, .cls-3, .cls-4 { + stroke-width: 0px; + } + + .cls-2, .cls-3 { + fill: #016fd0; + } + + .cls-2, .cls-4 { + fill-rule: evenodd; + } + + .cls-4 { + fill: #fff; + } + </style> + </defs> + <rect class="cls-3" x="0" width="235" height="148" rx="16" ry="16"/> + <path class="cls-2" d="M153,4h70v70h-70V4Z"/> + <path class="cls-4" d="M178.4,62.9v-25.1h44.6v7l-5.2,5.5,5.2,5.6v7.1h-8.2l-4.4-4.8-4.3,4.9h-27.7Z"/> + <path class="cls-2" d="M181.3,60.2v-19.6h16.6v4.5h-11.2v3.1h11v4.4h-11v3h11.2v4.6h-16.6Z"/> + <path class="cls-2" d="M197.9,60.2l9.2-9.8-9.2-9.8h7.1l5.6,6.2,5.6-6.2h6.8v.2l-9,9.6,9,9.5v.3h-6.9l-5.7-6.3-5.7,6.3h-6.9Z"/> + <path class="cls-4" d="M180.4,15.6h10.8l3.8,8.6v-8.6h13.3l2.3,6.4,2.3-6.4h10.1v25.1h-53.6l11.1-25.1Z"/> + <path class="cls-2" d="M182.5,18.3l-8.7,19.6h6l1.6-3.9h8.9l1.6,3.9h6.1l-8.6-19.6h-6.9ZM183.2,29.6l2.6-6.2,2.6,6.2h-5.2Z"/> + <path class="cls-2" d="M197.9,37.9v-19.6h8.4s4.3,12.1,4.3,12.1l4.3-12.1h8.1v19.6h-5.2s0-13.4,0-13.4l-4.9,13.4h-4.7l-5-13.4v13.4h-5.3Z"/> + <path class="cls-1" d="M40,73.2h-3.8c-1,0-1.8.8-1.8,1.8v6.8c0,1,.8,1.8,1.8,1.8h3.8c1,0,1.8-.8,1.8-1.8v-6.8c0-1-.8-1.8-1.8-1.8Z"/> + <path class="cls-1" d="M40.1,62.9h-3.8c-1,0-1.8.8-1.8,1.8v5c0,1,.8,1.8,1.8,1.8h3.8c1,0,1.8-.8,1.8-1.8v-5c0-1-.8-1.8-1.8-1.8Z"/> + <path class="cls-1" d="M49.6,74.9h-3.8c-1,0-1.8.8-1.8,1.8v5c0,1,.8,1.8,1.8,1.8h3.8c1,0,1.8-.8,1.8-1.8v-5c0-1-.8-1.8-1.8-1.8Z"/> + <path class="cls-1" d="M49.4,62.9h-3.8c-1,0-1.8.8-1.8,1.8v7.2c0,1,.8,1.8,1.8,1.8h3.8c1,0,1.8-.8,1.8-1.8v-7.2c0-1-.8-1.8-1.8-1.8Z"/> + <path class="cls-1" d="M32.6,81.8v-17.4c0-.9-.8-1.6-1.7-1.6h-6.2c-.5,0-1,.2-1.3.6l-2.1,2.6c-.2.3-.4.6-.4,1v12.7c0,.3.1.7.3.9l1.6,2.2c.3.4.8.7,1.4.7h6.7c.9,0,1.7-.8,1.7-1.7h0Z"/> +</svg> \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg b/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg new file mode 100644 index 000000000000..9c0711fcaedc --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 28.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 68 68" style="enable-background:new 0 0 68 68;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#4ED7DE;} + .st1{fill:#99F6FF;} + .st2{fill:none;stroke:#002140;stroke-linecap:round;stroke-linejoin:round;} +</style> +<path class="st0" d="M12.75,37.082c0,0-5.432-7.6-3.379-12.306c2.048-4.71,4.946-12.07,18.824-12.19 + c13.877-0.12,18.223,12.791,17.501,17.136s-6.758,11.585-13.637,12.671s-10.138-0.12-10.138-0.12s-6.638,3.74-13.396,1.688 + C8.525,43.961,12.024,39.615,12.75,37.082z"/> +<path class="st1" d="M45.21,24.411c0,0,1.932,9.777-5.067,14.242c-6.033,3.62-9.171,4.706-17.257,4.105c0,0,0.726,4.946,6.277,8.205 + s12.671,3.985,15.93,2.533c0,0,9.172,3.139,13.517,1.567c0,0-4.225-5.552-4.105-7.239c0,0,4.826-4.586,3.62-10.863 + c-1.207-6.277-7.724-12.19-12.911-12.551H45.21z"/> +<path class="st2" d="M12.682,36.829c-0.457,3.551-4.277,7.223-4.277,7.223s9.584,1.022,13.942-1.607"/> +<path class="st2" d="M22.37,42.441c1.96,6.662,9.139,11.601,17.693,11.601c1.732,0,3.407-0.201,4.995-0.581"/> +<path class="st2" d="M54.723,47.845c2.241-2.537,3.564-5.672,3.564-9.071c0-6.851-5.387-12.647-12.803-14.579"/> +<path class="st2" d="M54.723,47.845C55.18,51.396,59,55.068,59,55.068s-9.584,1.022-13.942-1.607"/> +<path class="st2" d="M12.682,36.829c-2.241-2.537-3.564-5.672-3.564-9.071c0-8.43,8.157-15.264,18.223-15.264 + c10.065,0,18.223,6.834,18.223,15.268c0,8.434-8.157,15.268-18.223,15.268c-1.732,0-3.407-0.2-4.995-0.581"/> +</svg> diff --git a/assets/images/user-plus.svg b/assets/images/user-plus.svg new file mode 100644 index 000000000000..bd49633bf738 --- /dev/null +++ b/assets/images/user-plus.svg @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 20 20"> + <!-- Generator: Adobe Illustrator 28.6.0, SVG Export Plug-In . SVG Version: 1.2.0 Build 709) --> + <g> + <g id="Layer_1"> + <path d="M13.556,5.555c0,1.964-1.592,3.556-3.556,3.556s-3.556-1.592-3.556-3.556,1.592-3.556,3.556-3.556,3.556,1.592,3.556,3.556Z"/> + <path d="M10,16c0,.769.289,1.47.765,2.001h-6.543c-1.416,0-2.223-.444-2.223-1.778,0-3.556,4.001-6.223,8-6.223,1.355,0,2.71.306,3.91.849-.56.545-.908,1.307-.908,2.151-1.657,0-3,1.343-3,3Z"/> + <path d="M16,12c.552,0,1,.448,1,1v2h2c.552,0,1,.448,1,1s-.448,1-1,1h-2v2c0,.552-.448,1-1,1s-1-.448-1-1v-2h-2c-.552,0-1-.448-1-1s.448-1,1-1h2v-2c0-.552.448-1,1-1Z"/> + </g> + </g> +</svg> \ No newline at end of file diff --git a/docs/articles/new-expensify/getting-started/Upgrade-to-a-Collect-Plan.md b/docs/Hidden/Upgrade-to-a-Collect-Plan.md similarity index 100% rename from docs/articles/new-expensify/getting-started/Upgrade-to-a-Collect-Plan.md rename to docs/Hidden/Upgrade-to-a-Collect-Plan.md diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md b/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md index cae289a0526a..7d318fd35143 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments.md @@ -1,61 +1,29 @@ --- -title: Third Party Payments -description: A help article that covers Third Party Payment options including PayPal, Venmo, Wise, and Paylocity. +title: Third-Party Payments +description: Reimburse reports and pay bills using PayPal or Venmo. --- -# Expensify Third Party Payment Options - -Expensify offers convenient third party payment options that allow you to streamline the process of reimbursing expenses and managing your finances. With these options, you can pay your expenses and get reimbursed faster and more efficiently. In this guide, we'll walk you through the steps to set up and use Expensify's third party payment options. - -# Overview - -Expensify offers integration with various third party payment providers, making it easy to reimburse employees and manage your expenses seamlessly. Some of the key benefits of using third-party payment options in Expensify include: - +Expensify integrates with PayPal and Venmo, which can be used to reimburse employees or pay bills. Some of the key benefits of using a third-party payment provider are: - Faster Reimbursements: Expedite the reimbursement process and reduce the time it takes for employees to receive their funds. - Secure Transactions: Benefit from the security features and protocols provided by trusted payment providers. - Centralized Expense Management: Consolidate all your expenses and payments within Expensify for a more efficient financial workflow. -# Setting Up Third Party Payments - -To get started with third party payments in Expensify, follow these steps: - -1. **Log in to Expensify**: Access your Expensify account using your credentials. - -2. **Navigate to Settings**: Click on the "Settings" option in the top-right corner of the Expensify dashboard. - -3. **Select Payments**: In the Settings menu, find and click on the "Payments" or "Payment Methods" section. - -4. **Choose Third Party Payment Provider**: Select your preferred third party payment provider from the available options. Expensify may support providers such as PayPal, Venmo, Wise, and Paylocity. - -5. **Link Your Account**: Follow the prompts to link your third party payment account with Expensify. You may need to enter your account details and grant necessary permissions. +# Connect a third-party payment option -6. **Verify Your Account**: Confirm your linked account to ensure it's correctly integrated with Expensify. - -# Using Third Party Payments - -Once you've set up your third party payment option, you can start using it to reimburse expenses and manage payments: - -1. **Create an Expense Report**: Begin by creating an expense report in Expensify, adding all relevant expenses. - -2. **Submit for Approval**: After reviewing and verifying the expenses, submit the report for approval within Expensify. - -3. **Approval and Reimbursement**: Once the report is approved, the approved expenses can be reimbursed directly through your chosen third party payment provider. Expensify will automatically initiate the payment process. - -4. **Track Payment Status**: You can track the status of payments and view transaction details within your Expensify account. +To connect a third-party payment platform to Expensify: +1. Log into your Expensify web account +2. Head to **Settings > Account > Payments > Alternative Payment Accounts** +3. Choose PayPal or Venmo + - **PayPal**: Enter your username in the `paypal.me/` field + - **Venmo**: Receive invoices via Venmo by adding your mobile phone number as a Secondary Login {% include faq-begin.md %} -## Q: Are there any fees associated with using third party payment options in Expensify? - -A: The fees associated with third party payments may vary depending on the payment provider you choose. Be sure to review the terms and conditions of your chosen provider for details on any applicable fees. - -## Q: Can I use multiple third party payment providers with Expensify? - -A: Expensify allows you to link multiple payment providers if needed. You can select the most suitable payment method for each expense report. +## Can I use multiple third-party payment providers with Expensify? -## Q: Is there a limit on the amount I can reimburse using third party payments? +Yes, you can link both your Venmo and PayPal accounts to Expensify if you'd like. -A: The reimbursement limit may depend on the policies and settings configured within your Expensify account and the limits imposed by your chosen payment provider. +## Is there a limit on the amount I can reimburse using third party payments? -With Expensify's third party payment options, you can simplify your expense management and reimbursement processes. By following the steps outlined in this guide, you can set up and use third party payments efficiently. +The payment limit is dependent on the settings configured within your Expensify account as well as the limits imposed by the third-party payment provider. {% include faq-end.md %} diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Get-reimbursed-faster-as-a-non-US-employee.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Get-reimbursed-faster-as-a-non-US-employee.md new file mode 100644 index 000000000000..30dea99bbfde --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Get-reimbursed-faster-as-a-non-US-employee.md @@ -0,0 +1,24 @@ +--- +title: Get Reimbursed Faster as a Non-US Employee +description: How to use Wise to get paid faster +--- + +If you are an overseas employee who works for a US-based company, you can use Wise to be reimbursed for expenses just as quickly as your US-based colleagues. Wise (formerly TransferWise) is an FCA-regulated global money transfer service. + +Hereβs how it works: + +1. When you sign up for a Wise account, you are provided with a USD checking account number and a routing number to use as your Expensify bank account. +2. Once you receive a reimbursement, it will be deposited directly into your Wise account. +3. You can then convert your funds into 40+ different currencies and withdraw them to your local bank account. If you live in the UK or EU, you can also get a debit card to spend money directly from your Wise account. + +## Set up reimbursements through Wise + +1. Check with your company to see if you can submit your expenses in USD. +2. Sign up for a Wise Borderless Account and get verified (verification can take up to 3 days). +3. In Expensify, [add a deposit-only bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account) with your Wise USD account and ACH routing numbers (NOT the wire transfer routing number). + +{% include info.html %} +Do not include spaces in the Wise account number, which should be 16 digits. +{% include end-info.html %} + +If your expenses are not in USD, Expensify will automatically convert them to USD when they are added to your expense report. Once you submit your expenses to your companyβs USD workspace and they are approved, you will receive the reimbursement for the approved report total in USD in your Wise account. diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md deleted file mode 100644 index b2cfbf833e13..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: Reimburse reports, invoices, and bills -description: Use direct deposit or indirect reimbursement to pay reports, invoices, and bills ---- -<div id="expensify-classic" markdown="1"> - -Once a report, invoice, or bill has been submitted and approved for reimbursement, you can reimburse the expenses using direct deposit or an indirect reimbursement option outside of Expensify (like cash, a check, or a third-party payment processor). - -# Pay with direct deposit - -{% include info.html %} -Before a report can be reimbursed with direct deposit, the employee or vendor receiving the reimbursement must connect their personal U.S. bank account, and the reimburser must connect a verified business bank account. - -Direct deposit is available for U.S. and global reimbursements. It is not available for Australian bank accounts. For Australian accounts, review the process for reimbursing Australian expenses. -{% include end-info.html %} - -1. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab. -2. Click the **Reimburse** (for reports) or **Pay** (for bills and invoices) dropdown and select **Via Direct Deposit (ACH)**. -3. Confirm that the correct VBA is selected or use the dropdown menu to select a different one. -4. Click **Accept Terms & Pay**. - -The reimbursement is now queued in the daily batch. - -# Pay with indirect reimbursement - -When payments are submitted through Expensify, the report is automatically labeled as Reimbursed after it has been paid. However, if you are reimbursing reports via paper check, payroll, or any other method that takes place outside of Expensify, youβll want to manually mark the bill as paid in Expensify to track the payment history. - -To label a report as Reimbursed after sending a payment outside of Expensify, - -1. Pay the report, invoice, or bill outside of Expensify. -2. Open the report, invoice, or bill from the email or Concierge notification, or from the **Reports** tab. -3. Click **Reimburse**. -4. Select **Iβll do it manually - just mark it as reimbursed**. This changes the report status to Reimbursed. - -Once the recipient has received the payment, the submitter can return to the report and click **Confirm** at the top of the report. This will change the report status to Reimbursed: CONFIRMED. - -{% include faq-begin.md %} - -**Is there a maximum total report total?** - -Expensify cannot process a reimbursement for any single report over $20,000. If you have a report with expenses exceeding $20,000 we recommend splitting the expenses into multiple reports. - -**Why is my account locked?** - -When you reimburse a report, you authorize Expensify to withdraw the funds from your account and send them to the person requesting reimbursement. If your bank rejects Expensifyβs withdrawal request, your verified bank account is locked until the issue is resolved. - -Withdrawal requests can be rejected if the bank account has not been enabled for direct debit or due to insufficient funds. If you need to enable direct debits from your verified bank account, your bank will require the following details: -- The ACH CompanyIDs: 1270239450 and 4270239450 -- The ACH Originator Name: Expensify - -Once resolved, you can request to unlock the bank account by completing the following steps: - -1. Hover over **Settings**, then click **Account**. -2. Click the **Payments** tab. -3. Click **Bank Accounts**. -4. Next to the bank account, click **Fix**. - -Our support team will review and process the request within 4-5 business days. - -**How are bills and invoices processed in Expensify?** - -Here is the process a vendor or supplier bill goes through from receipt to payment: - -1. A vendor or supplier bill is received in Expensify. -2. Automatically, the document is SmartScanned and a bill is created for the primary domain contact. The bill will appear under the Reports tab on their default group policy. -3. When the bill is ready for processing, it is submitted and follows the primary domain contactβs approval workflow until the bill has been fully approved. -4. The final approver pays the bill from their Expensify account using one of the methods outlined in the article above. -5. If the workspace is connected to an accounting integration, the bill is automatically coded with the relevant imported GL codes and can be exported back to the accounting software. - -**When a vendor or supplier bill is sent to Expensify, who receives it?** - -Bills are sent to the primary contact for the domain. Theyβll see a notification from Concierge on their Home page, and theyβll also receive an email. - -**How can I share access to bills?** - -By default, only the primary contact for the domain can view and pay the bill. However, you can allow someone else to view or pay bills. - -- **To allow someone to view a bill**: The primary contact can manually share the bill with others to allow them to view it. - 1. Click the **Reports** tab. - 2. Click the report. - 3. Click **Details** in the top right. - 4. Click the **Add Person** icon. - 5. Enter the email address or phone number of the person you will share the report with. - 6. Enter a message, if desired. - 7. Click **Share Report**. - -- **To allow someone to pay bills**: The primary domain contact can allow others to pay bills on their behalf by [assigning those individuals as Copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot). - -**Is Bill Pay supported internationally?** - -Payments are currently only supported for users paying in United States Dollars (USD). - -**Whatβs the difference between a bill and an invoice?** - -- A **bill** is a payable that represents an amount owed to a payee (usually a vendor or supplier), and it is usually created from a vendor invoice. -- An **invoice** is a receivable that indicates an amount owed to you by someone else. - -**Who can reimburse reports?** - -Only a Workspace Admin who has added a verified business bank account to their Expensify account can reimburse employee reports. - -**Why canβt I trigger direct ACH reimbursements in bulk?** - -Expensify does not offer bulk reimbursement, but you can set up automatic reimbursement to automatically reimburse approved reports via ACH that do not exceed the threshold that you define. - -{% include faq-end.md %} - -</div> diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md new file mode 100644 index 000000000000..afe366fb1dbe --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md @@ -0,0 +1,95 @@ +--- +title: Reimburse Reports +description: +--- +<div id="expensify-classic" markdown="1"> + +Once a report is submitted and approved, you can reimburse the expenses directly via direct deposit or global reimbursement, use an indirect reimbursement method (such as a third-party payment processor), or mark the report as reimbursed outside of Expensify (if your organization bundles reimbursements in payroll, for instance). + +## Direct Deposit - USD + +Before a report can be reimbursed via direct deposit: +- The reimburser must [connect a verified business bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account) +- The recipient must [connect a personal bank account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account) + +To reimburse a report via direct deposit (USD): +1. Open the report. +2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**. +3. Confirm that the correct bank account is listed in the dropdown menu. +4. Click **Accept Terms & Pay**. + +If the reimbursement is less than $200, it will typically be deposited into the employee's bank account immediately. If the reimbursement is more than $200, the deposit will be processed within one to five business days. + +## Direct Deposit - Global Reimbursement +Before a report can be reimbursed via global reimbursement: +- A workspace admin must [set up global reimbursements](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements) +- Employees must [connect a deposit account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account) + +To reimburse a report via global reimbursement: +1. Open the report. +2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**. +3. Confirm that the correct bank account is listed in the dropdown menu. +4. Click **Accept Terms & Pay**. + +The reimbursement should be processed within five business days. If the payment hasn't been processed within that timeframe, reach out to Expensify Support for assistance. + +## Indirect Reimbursement +If you are reimbursing reports outside of Expensify via paper check or payroll, youβll want to manually mark the report as paid to track the payment history. + +To label a report as Reimbursed after sending a payment outside of Expensify: +1. Open the report +2. Click **Reimburse**. +3. Select **Iβll do it manually - just mark it as reimbursed**. This changes the report status to Reimbursed. + +Once the recipient has received the payment, the submitter can return to the report and click **Confirm**. This will change the report status to **`Reimbursed: CONFIRMED`**. + +### Reimburse a report via a third-party payment provider + +If both the reimburser and the payment recipient have Venmo accounts, you can [connect them directly to Expensify](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments) to send and receive reimbursements. + +### Reimburse a report via ABA batch file +Workspace Admins can reimburse AUD expense reports by downloading an ABA file containing the accounts needing payment and uploading the file to the bank. This can be done for a single report or for a batch of payments. + +More information on reimbursing reports via ABA batch file can be found **[here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Australian-Reports)**. + +{% include faq-begin.md %} + +## Is there a maximum report total? + +Expensify cannot process a reimbursement for any single report over $20,000. If you have a report with expenses exceeding $20,000 we recommend splitting the expenses into multiple reports. + +## Why is my business bank account locked? + +When you reimburse a report, you authorize Expensify to withdraw the funds from your account and send them to the person requesting reimbursement. If your bank rejects Expensifyβs withdrawal request, your verified bank account is locked until the issue is resolved. + +Withdrawal requests can be rejected if the bank account has not been enabled for direct debit or due to insufficient funds. If you need to enable direct debits from your verified bank account, your bank will require the following details: +- The ACH CompanyIDs: 1270239450 and 4270239450 +- The ACH Originator Name: Expensify + +Once resolved, you can request to unlock the bank account by completing the following steps: +1. Hover over **Settings**, then click **Account**. +2. Click the **Payments** tab. +3. Click **Bank Accounts**. +4. Next to the bank account, click **Fix**. + +Our support team will review and process the request within 4-5 business days. + +## Who can reimburse reports? + +Only a Workspace Admin who has added a verified business bank account connected to their Expensify account can reimburse employee reports. + +## How can I add another employee as a reimburser? + +You can give another employee access to reimburse reports by doing the following: +1. If they're not already a workspace admin, add them as one under **Settings > Workspaces > [Workspace Name] > Members**. +2. Share the business bank account with them by heading to **Settings > Account > Payments** and clicking **Share**. +3. The new reimburser will need to validate the shared bank connection by entering the test deposits that Expensify sends to the bank account. +4. Once validated, the employee will have access to reimburse reports. You can make them the default reimburser for all reports submitted on a specific workspace by selecting them from the dropdown menu under **Settings > Workspaces > [Workspace Name] > Reimbursements > Reimburser**. + +## Why canβt I trigger direct ACH reimbursements in bulk? + +Expensify does not offer bulk reimbursement, but you can automate reimbursements by setting a threshold amount under **Settings > Workspaces > [Workspace Name] > Reimbursement**. After setting a threshold amount, an employee's reimbursement is triggered once a report is **Final Approved**. If the total of a report is more than the threshold amount, the reimbursement will need to be manually triggered. + +{% include faq-end.md %} + +</div> diff --git a/docs/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses.md b/docs/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses.md index 54cbcfcb52c3..26634d9a33df 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses.md +++ b/docs/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses.md @@ -6,8 +6,8 @@ description: Approve, hold, or pay expenses submitted to you As a workspace admin, you can set an approval workflow for the expenses submitted to you. Expenses can be, -- Instantly submitted without needing approval. -- Submitted at a desired frequency (daily, weekly, monthly) and follow an approval workflow. +- Instantly submitted without needing approval. +- Submitted at a desired frequency (daily, weekly, monthly) and follow an approval workflow. **Setting approval workflow and submission frequencies** diff --git a/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md b/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md index f9491892693a..bac8c52d361e 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md +++ b/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md @@ -32,6 +32,7 @@ To pay an invoice, You can also view all unpaid invoices by searching for the senderβs email or phone number on the left-hand side of the app. The invoices waiting for your payment will have a green dot. +{% include faq-begin.md %} # FAQ **Can someone else pay an invoice besides the person who received it?** @@ -53,6 +54,6 @@ You will need to work with the vendor to discuss alternative payment options. Yo You can add additional payment methods to your [Wallet](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Set-up-your-wallet). Click **Account Settings** > **Wallet** > click **Add Bank Account**. You will be prompted to choose a payment method when paying future invoices. - +{% include faq-end.md %} </div> diff --git a/docs/redirects.csv b/docs/redirects.csv index 480fd4220bd4..55cfd267f651 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -542,11 +542,11 @@ https://community.expensify.com/discussion/8118/how-to-redeem-deel-com-perk,http https://community.expensify.com/discussion/8256/how-to-redeem-25-off-slack-with-the-expensify-card,https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Perks#slack https://community.expensify.com/discussion/8737/exclusive-perks-for-expensify-card-members,https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Perks https://community.expensify.com/discussion/9040/how-to-redeem-10-off-netsuite-with-the-expensify-card,https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Perks#netsuite -https://community.expensify.com/discussion/4828/how-to-match-your-company-cards-statement-to-expensify/p1?new=1,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation +https://community.expensify.com/discussion/4828/how-to-match-your-company-cards-statement-to-expensify,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation https://community.expensify.com/discussion/5580/deep-dive-configure-advanced-settings-for-netsuite/,https://help.expensify.com/articles/expensify-classic/connections/netsuite/Configure-Netsuite#step-3-configure-advanced-settings -https://community.expensify.com/discussion/7231/how-to-export-invoices-to-netsuite/p1?new=1,https://help.expensify.com/articles/expensify-classic/connections/netsuite/Configure-Netsuite#export-invoices +https://community.expensify.com/discussion/7231/how-to-export-invoices-to-netsuite/,https://help.expensify.com/articles/expensify-classic/connections/netsuite/Configure-Netsuite#export-invoices https://community.expensify.com/discussion/9168/how-to-troubleshoot-general-errors-when-uploading-your-id-via-onfido,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Resolve-Errors-Adding-a-Bank-Account -https://community.expensify.com/discussion/4707/how-to-set-up-your-mobile-app,https://use.expensify.com/expensify-mobile-app +https://community.expensify.com/discussion/4707/how-to-set-up-your-mobile-app/,https://use.expensify.com/expensify-mobile-app https://community.expensify.com/discussion/7066/introducing-concierge-travel,https://help.expensify.com/expensify-classic/hubs/travel/ https://help.expensify.com/expensify-classic/hubs/integrations/,https://help.expensify.com/expensify-classic/hubs/connections/ https://help.expensify.com/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit,https://help.expensify.com/articles/expensify-classic/reports/Automatically-submit-employee-reports @@ -554,7 +554,6 @@ https://help.expensify.com/articles/expensify-classic/expensify-card/Set-Up-the- https://community.expensify.com/discussion/5542/deep-dive-what-are-ereceipts,https://help.expensify.com/articles/expensify-classic/workspaces/Expense-Settings#ereceipts https://community.expensify.com/discussion/5738/deep-dive-how-does-concierge-receipt-audit-work,https://help.expensify.com/articles/expensify-classic/workspaces/Expense-Settings#concierge-receipt-audit https://community.expensify.com/discussion/4643/how-to-invite-people-to-your-policy-using-a-join-link,https://help.expensify.com/articles/expensify-classic/workspaces/Invite-members-and-assign-roles#invite-with-a-link -https://community.expensify.com/discussion/4975/how-to-invite-users-to-your-policy-manually-or-in-bulk/p1?new=1,https://help.expensify.com/articles/expensify-classic/workspaces/Invite-members-and-assign-roles https://help.expensify.com/articles/expensify-classic/workspaces/Invoicing,https://help.expensify.com/articles/expensify-classic/workspaces/Set-Up-Invoicing https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements.md,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements https://help.expensify.com/articles/expensify-classic/integrations/travel-integrations/Trip-Actions,https://help.expensify.com/expensify-classic/hubs/connections/ @@ -565,7 +564,8 @@ https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Global-Reimbursements,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Enable-Global-Reimbursements https://community.expensify.com/discussion/4641/how-to-add-a-deposit-only-bank-account-both-personal-and-business,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account https://community.expensify.com/discussion/5940/how-to-get-reimbursed-outside-the-us-with-wise-for-non-us-employees,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments -https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fqbo.intuit.com%2Fapp%2Fvendors,https://help.expensify.com/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting -https://community.expensify.com/discussion/5654/deep-dive-using-expense-rules-to-vendor-match-when-exporting-to-an-accounting-package/p1?new=1,https://help.expensify.com/articles/expensify-classic/connections/xero/Xero-Troubleshooting -https://help.expensify.com/articles/expensify-classic/spending-insights/(https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates),https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates +https://help.expensify.com/articles/expensify-classic/spending-insights,https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-notifications,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-Notifications +https://help.expensify.com/articles/new-expensify/getting-started/Upgrade-to-a-Collect-Plan,https://help.expensify.com/Hidden/Upgrade-to-a-Collect-Plan +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports +https://help.expensify.com/articles/new-expensify/expenses-&-payments/pay-an-invoice.html,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Pay-an-invoice diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 2560e48728c5..15eb36c819b5 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -15,9 +15,79 @@ require 'ostruct' skip_docs opt_out_usage +KEY_GRADLE_APK_PATH = "gradleAPKOutputPath" +KEY_GRADLE_AAB_PATH = "gradleAABOutputPath" +KEY_IPA_PATH = "ipaPath" +KEY_DSYM_PATH = "dsymPath" + +# Export environment variables in the parent shell. +# In a GitHub Actions environment, it will save the environment variables in the GITHUB_ENV file. +# In any other environment, it will save them to the current shell environment using the `export` command. +def exportEnvVars(env_vars) + github_env_path = ENV['GITHUB_ENV'] + if github_env_path && File.exist?(github_env_path) + puts "Saving environment variables in GITHUB_ENV..." + File.open(github_env_path, "a") do |file| + env_vars.each do |key, value| + puts "#{key}=#{value}" + file.puts "#{key}=#{value}" + end + end + else + puts "Saving environment variables in parent shell..." + env_vars.each do |key, value| + puts "#{key}=#{value}" + command = "export #{key}=#{value}" + system(command) + end + end +end + +def setGradleOutputsInEnv() + puts "Saving Android build outputs in env..." + env_vars = { + KEY_GRADLE_APK_PATH => lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH], + } + if lane_context.key?(SharedValues::GRADLE_AAB_OUTPUT_PATH) + env_vars[KEY_GRADLE_AAB_PATH] = lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH] + end + exportEnvVars(env_vars) +end + +def setIOSBuildOutputsInEnv() + puts "Saving iOS build outputs in env..." + exportEnvVars({ + KEY_IPA_PATH => lane_context[SharedValues::IPA_OUTPUT_PATH], + KEY_DSYM_PATH => lane_context[SharedValues::DSYM_OUTPUT_PATH], + }) +end + platform :android do - desc "Generate a new local APK for e2e testing" + desc "Generate a production AAB" + lane :build do + ENV["ENVFILE"]=".env.production" + gradle( + project_dir: './android', + task: 'bundle', + flavor: 'Production', + build_type: 'Release', + ) + setGradleOutputsInEnv() + end + desc "Generate a new local APK" + lane :build_local do + ENV["ENVFILE"]=".env.production" + gradle( + project_dir: './android', + task: 'assemble', + flavor: 'Production', + build_type: 'Release', + ) + setGradleOutputsInEnv() + end + + desc "Generate a new local APK for e2e testing" lane :build_e2e do ENV["ENVFILE"]="tests/e2e/.env.e2e" ENV["ENTRY_FILE"]="src/libs/E2E/reactNativeLaunchingTest.ts" @@ -29,6 +99,7 @@ platform :android do flavor: 'e2e', build_type: 'Release', ) + setGradleOutputsInEnv() end lane :build_e2edelta do @@ -42,68 +113,50 @@ platform :android do flavor: 'e2edelta', build_type: 'Release', ) + setGradleOutputsInEnv() end - desc "Generate a new local APK" - lane :build do - ENV["ENVFILE"]=".env.production" - + desc "Build AdHoc testing build" + lane :build_adhoc do + ENV["ENVFILE"]=".env.adhoc" gradle( project_dir: './android', task: 'assemble', - flavor: 'Production', + flavor: 'Adhoc', build_type: 'Release', ) + setGradleOutputsInEnv() end - desc "Build app for testing" - lane :build_internal do - ENV["ENVFILE"]=".env.adhoc" - - gradle( - project_dir: './android', - task: 'assemble', - flavor: 'Adhoc', - build_type: 'Release', - ) - + desc "Upload build to S3" + lane :upload_s3 do + puts "APK path: #{ENV[KEY_GRADLE_APK_PATH]}" aws_s3( access_key: ENV['S3_ACCESS_KEY'], secret_access_key: ENV['S3_SECRET_ACCESS_KEY'], bucket: ENV['S3_BUCKET'], region: ENV['S3_REGION'], - - apk: lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH], + apk: ENV[KEY_GRADLE_APK_PATH], app_directory: "android/#{ENV['PULL_REQUEST_NUMBER']}", ) - sh("echo '{\"apk_path\": \"#{lane_context[SharedValues::S3_APK_OUTPUT_PATH]}\",\"html_path\": \"#{lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}\"}' > ../android_paths.json") end - desc "Build and upload app to Google Play" - lane :beta do - ENV["ENVFILE"]=".env.production" + desc "Upload app to Google Play for internal testing" + lane :upload_google_play_internal do # Google is very unreliable, so we retry a few times ENV["SUPPLY_UPLOAD_MAX_RETRIES"]="5" - - gradle( - project_dir: './android', - task: 'bundle', - flavor: 'Production', - build_type: 'Release', - ) - upload_to_play_store( - package_name: "com.expensify.chat", - json_key: './android/app/android-fastlane-json-key.json', - aab: './android/app/build/outputs/bundle/productionRelease/app-production-release.aab', - track: 'internal', - rollout: '1.0' + package_name: "com.expensify.chat", + json_key: './android/app/android-fastlane-json-key.json', + aab: ENV[KEY_GRADLE_AAB_PATH], + track: 'internal', + rollout: '1.0' ) end desc "Deploy app to Google Play production" - lane :production do + lane :upload_google_play_production do # Google is very unreliable, so we retry a few times ENV["SUPPLY_UPLOAD_MAX_RETRIES"]="5" google_play_track_version_codes( @@ -111,7 +164,6 @@ platform :android do json_key: './android/app/android-fastlane-json-key.json', track: 'internal' ) - upload_to_play_store( package_name: "com.expensify.chat", json_key: './android/app/android-fastlane-json-key.json', @@ -129,118 +181,114 @@ platform :android do end end +def setupIOSSigningCertificate() + require 'securerandom' + keychain_password = SecureRandom.uuid + + create_keychain( + name: "ios-build.keychain", + password: keychain_password, + default_keychain: "true", + unlock: "true", + timeout: "3600", + add_to_search_list: "true" + ) + + import_certificate( + certificate_path: "./ios/Certificates.p12", + keychain_name: "ios-build.keychain", + keychain_password: keychain_password + ) +end + platform :ios do - desc "Generate a local iOS production build" + desc "Build an iOS production build" lane :build do ENV["ENVFILE"]=".env.production" + setupIOSSigningCertificate() + + install_provisioning_profile( + path: "./ios/NewApp_AppStore.mobileprovision" + ) + + install_provisioning_profile( + path: "./ios/NewApp_AppStore_Notification_Service.mobileprovision" + ) + + build_app( + workspace: "./ios/NewExpensify.xcworkspace", + scheme: "New Expensify", + output_name: "New Expensify.ipa", + export_options: { + provisioningProfiles: { + "com.chat.expensify.chat" => "(NewApp) AppStore", + "com.chat.expensify.chat.NotificationServiceExtension" => "(NewApp) AppStore: Notification Service", + }, + manageAppVersionAndBuildNumber: false + } + ) + + setIOSBuildOutputsInEnv() + end + + desc "Build an unsigned iOS production build" + lane :build_unsigned do + ENV["ENVFILE"]=".env.production" build_app( workspace: "./ios/NewExpensify.xcworkspace", scheme: "New Expensify" ) + setIOSBuildOutputsInEnv() end - desc "Build app for testing" - lane :build_internal do - require 'securerandom' + desc "Build AdHoc app for testing" + lane :build_adhoc do ENV["ENVFILE"]=".env.adhoc" - keychain_password = SecureRandom.uuid - - create_keychain( - name: "ios-build.keychain", - password: keychain_password, - default_keychain: "true", - unlock: "true", - timeout: "3600", - add_to_search_list: "true" - ) - - import_certificate( - certificate_path: "./ios/Certificates.p12", - keychain_name: "ios-build.keychain", - keychain_password: keychain_password - ) + setupIOSSigningCertificate() install_provisioning_profile( - path: "./ios/NewApp_AdHoc.mobileprovision" + path: "./ios/NewApp_AdHoc.mobileprovision" ) install_provisioning_profile( - path: "./ios/NewApp_AdHoc_Notification_Service.mobileprovision" + path: "./ios/NewApp_AdHoc_Notification_Service.mobileprovision" ) build_app( - workspace: "./ios/NewExpensify.xcworkspace", - skip_profile_detection: true, - scheme: "New Expensify AdHoc", - export_method: "ad-hoc", - export_options: { - method: "ad-hoc", - provisioningProfiles: { - "com.expensify.chat.adhoc" => "(NewApp) AdHoc", - "com.expensify.chat.adhoc.NotificationServiceExtension" => "(NewApp) AdHoc: Notification Service", - }, - manageAppVersionAndBuildNumber: false - } + workspace: "./ios/NewExpensify.xcworkspace", + skip_profile_detection: true, + scheme: "New Expensify AdHoc", + export_method: "ad-hoc", + export_options: { + method: "ad-hoc", + provisioningProfiles: { + "com.expensify.chat.adhoc" => "(NewApp) AdHoc", + "com.expensify.chat.adhoc.NotificationServiceExtension" => "(NewApp) AdHoc: Notification Service", + }, + manageAppVersionAndBuildNumber: false + } ) + setIOSBuildOutputsInEnv() + end + desc "Upload app to S3" + lane :upload_s3 do + puts "IPA path: #{ENV[KEY_IPA_PATH]}" aws_s3( access_key: ENV['S3_ACCESS_KEY'], secret_access_key: ENV['S3_SECRET_ACCESS_KEY'], bucket: ENV['S3_BUCKET'], region: ENV['S3_REGION'], - - ipa: lane_context[SharedValues::IPA_OUTPUT_PATH], + ipa: ENV[KEY_IPA_PATH], app_directory: "ios/#{ENV['PULL_REQUEST_NUMBER']}", ) - sh("echo '{\"ipa_path\": \"#{lane_context[SharedValues::S3_IPA_OUTPUT_PATH]}\",\"html_path\": \"#{lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}\"}' > ../ios_paths.json") end - desc "Build and upload app to TestFlight" - lane :beta do - require 'securerandom' - ENV["ENVFILE"]=".env.production" - - keychain_password = SecureRandom.uuid - - create_keychain( - name: "ios-build.keychain", - password: keychain_password, - default_keychain: "true", - unlock: "true", - timeout: "3600", - add_to_search_list: "true" - ) - - import_certificate( - certificate_path: "./ios/Certificates.p12", - keychain_name: "ios-build.keychain", - keychain_password: keychain_password - ) - - install_provisioning_profile( - path: "./ios/NewApp_AppStore.mobileprovision" - ) - - install_provisioning_profile( - path: "./ios/NewApp_AppStore_Notification_Service.mobileprovision" - ) - - build_app( - workspace: "./ios/NewExpensify.xcworkspace", - scheme: "New Expensify", - output_name: "New Expensify.ipa", - export_options: { - provisioningProfiles: { - "com.chat.expensify.chat" => "(NewApp) AppStore", - "com.chat.expensify.chat.NotificationServiceExtension" => "(NewApp) AppStore: Notification Service", - }, - manageAppVersionAndBuildNumber: false - } - ) - + desc "Upload app to TestFlight" + lane :upload_testflight do upload_to_testflight( api_key_path: "./ios/ios-fastlane-json-key.json", distribute_external: true, @@ -249,30 +297,31 @@ platform :ios do groups: ["Beta"], demo_account_required: true, beta_app_review_info: { - contact_email: ENV["APPLE_CONTACT_EMAIL"], - contact_first_name: "Andrew", - contact_last_name: "Gable", - contact_phone: ENV["APPLE_CONTACT_PHONE"], - demo_account_name: ENV["APPLE_DEMO_EMAIL"], - demo_account_password: ENV["APPLE_DEMO_PASSWORD"], - notes: "1. In the Expensify app, enter the email 'appletest.expensify@proton.me'. This will trigger a sign-in link to be sent to 'appletest.expensify@proton.me' - 2. Navigate to https://account.proton.me/login, log into Proton Mail using 'appletest.expensify@proton.me' as email and the password associated with 'appletest.expensify@proton.me', provided above - 3. Once logged into Proton Mail, navigate to your inbox and locate the email triggered in step 1. The email subject should be 'Your magic sign-in link for Expensify' - 4. Open the email and copy the 6-digit sign-in code provided within - 5. Return to the Expensify app and enter the copied 6-digit code in the designated login field" + contact_email: ENV["APPLE_CONTACT_EMAIL"], + contact_first_name: "Andrew", + contact_last_name: "Gable", + contact_phone: ENV["APPLE_CONTACT_PHONE"], + demo_account_name: ENV["APPLE_DEMO_EMAIL"], + demo_account_password: ENV["APPLE_DEMO_PASSWORD"], + notes: "1. In the Expensify app, enter the email 'appletest.expensify@proton.me'. This will trigger a sign-in link to be sent to 'appletest.expensify@proton.me' + 2. Navigate to https://account.proton.me/login, log into Proton Mail using 'appletest.expensify@proton.me' as email and the password associated with 'appletest.expensify@proton.me', provided above + 3. Once logged into Proton Mail, navigate to your inbox and locate the email triggered in step 1. The email subject should be 'Your magic sign-in link for Expensify' + 4. Open the email and copy the 6-digit sign-in code provided within + 5. Return to the Expensify app and enter the copied 6-digit code in the designated login field" } ) + puts "dsym path: #{ENV[KEY_DSYM_PATH]}" upload_symbols_to_crashlytics( app_id: "1:921154746561:ios:216bd10ccc947659027c40", - dsym_path: lane_context[SharedValues::DSYM_OUTPUT_PATH], + dsym_path: ENV[KEY_DSYM_PATH], gsp_path: "./ios/GoogleService-Info.plist", binary_path: "./ios/Pods/FirebaseCrashlytics/upload-symbols" ) end - desc "Move app to App Store Review" - lane :production do + desc "Submit app to App Store Review" + lane :submit_for_review do deliver( api_key_path: "./ios/ios-fastlane-json-key.json", @@ -309,7 +358,6 @@ platform :ios do # Precheck cannot check for in app purchases with the API key we use precheck_include_in_app_purchases: false, submission_information: { - # We currently do not use idfa: https://developer.apple.com/app-store/user-privacy-and-data-use/ add_id_info_uses_idfa: false, @@ -334,6 +382,5 @@ platform :ios do 'en-US' => "Improvements and bug fixes" } ) - end end diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index d81aadc40697..19e80e80c59e 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -41,7 +41,7 @@ D27CE6B77196EF3EF450EEAC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0D3F9E814828D91464DF9D35 /* PrivacyInfo.xcprivacy */; }; DD79042B2792E76D004484B4 /* RCTBootSplash.mm in Sources */ = {isa = PBXBuildFile; fileRef = DD79042A2792E76D004484B4 /* RCTBootSplash.mm */; }; DDCB2E57F334C143AC462B43 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D20D83B0E39BA6D21761E72 /* ExpoModulesProvider.swift */; }; - E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = E9DF872C2525201700607FDC /* AirshipConfig.plist */; }; ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 8B28D84EF339436DBD42A203 /* ExpensifyNeue-BoldItalic.otf */; }; F0C450EA2705020500FD2970 /* colors.json in Resources */ = {isa = PBXBuildFile; fileRef = F0C450E92705020500FD2970 /* colors.json */; }; @@ -171,8 +171,8 @@ buildActionMask = 2147483647; files = ( 383643682B6D4AE2005BB9AE /* DeviceCheck.framework in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, 8744C5400E24E379441C04A4 /* libPods-NewExpensify.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index f676f248fb8c..a353e6df0319 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> - <string>9.0.29</string> + <string>9.0.33</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleURLTypes</key> @@ -40,7 +40,7 @@ </dict> </array> <key>CFBundleVersion</key> - <string>9.0.29.6</string> + <string>9.0.33.0</string> <key>FullStory</key> <dict> <key>OrgId</key> diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 5eb312548f35..6120d23bd1db 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ <key>CFBundlePackageType</key> <string>BNDL</string> <key>CFBundleShortVersionString</key> - <string>9.0.29</string> + <string>9.0.33</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> - <string>9.0.29.6</string> + <string>9.0.33.0</string> </dict> </plist> diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index c13e6f18dc1e..0ee85ea3f8f5 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ <key>CFBundleName</key> <string>$(PRODUCT_NAME)</string> <key>CFBundleShortVersionString</key> - <string>9.0.29</string> + <string>9.0.33</string> <key>CFBundleVersion</key> - <string>9.0.29.6</string> + <string>9.0.33.0</string> <key>NSExtension</key> <dict> <key>NSExtensionPointIdentifier</key> diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8b34d0e61eba..db50f22daba9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2407,13 +2407,13 @@ PODS: - Yoga - RNLocalize (2.2.6): - React-Core - - rnmapbox-maps (10.1.26): + - rnmapbox-maps (10.1.30): - MapboxMaps (~> 10.18.2) - React - React-Core - - rnmapbox-maps/DynamicLibrary (= 10.1.26) + - rnmapbox-maps/DynamicLibrary (= 10.1.30) - Turf - - rnmapbox-maps/DynamicLibrary (10.1.26): + - rnmapbox-maps/DynamicLibrary (10.1.30): - DoubleConversion - hermes-engine - MapboxMaps (~> 10.18.2) @@ -3237,7 +3237,7 @@ SPEC CHECKSUMS: RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 RNLiveMarkdown: cfc927fc0b1182e364237c72692e079107c6f5f1 RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 - rnmapbox-maps: 5ab6bfd249cd67262615153c648f8d809aab781c + rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4 RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 RNReactNativeHapticFeedback: a15b431d2903bc2eb3474ff8d9a05d3e67a70199 RNReanimated: 76901886830e1032f16bbf820153f7dc3f02d51d diff --git a/package-lock.json b/package-lock.json index e46eb0494ea7..940b8bcb4250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.29-6", + "version": "9.0.33-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.29-6", + "version": "9.0.33-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -43,7 +43,7 @@ "@react-navigation/native": "6.1.12", "@react-navigation/stack": "6.3.29", "@react-ng/bounds-observer": "^0.2.1", - "@rnmapbox/maps": "10.1.26", + "@rnmapbox/maps": "10.1.30", "@shopify/flash-list": "1.7.1", "@types/mime-db": "^1.43.5", "@ua/react-native-airship": "19.2.1", @@ -56,7 +56,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.83", + "expensify-common": "2.0.84", "expo": "51.0.17", "expo-av": "14.0.6", "expo-image": "1.12.12", @@ -96,7 +96,7 @@ "react-native-google-places-autocomplete": "2.5.6", "react-native-haptic-feedback": "^2.2.0", "react-native-image-picker": "^7.0.3", - "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#93399c6410de32966eb57085936ef6951398c2c3", + "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", "react-native-keyboard-controller": "^1.12.2", "react-native-launch-arguments": "^4.0.2", @@ -169,7 +169,7 @@ "@storybook/addon-a11y": "^8.1.10", "@storybook/addon-essentials": "^8.1.10", "@storybook/addon-webpack5-compiler-babel": "^3.0.3", - "@storybook/cli": "^8.1.10", + "@storybook/cli": "^8.3.0", "@storybook/react": "^8.1.10", "@storybook/react-webpack5": "^8.1.6", "@storybook/theming": "^8.1.10", @@ -232,6 +232,7 @@ "eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0", "html-webpack-plugin": "^5.5.0", "http-server": "^14.1.1", + "husky": "^9.1.5", "jest": "29.4.1", "jest-circus": "29.4.1", "jest-cli": "29.4.1", @@ -254,7 +255,7 @@ "setimmediate": "^1.0.5", "shellcheck": "^1.1.0", "source-map": "^0.7.4", - "storybook": "^8.1.10", + "storybook": "^8.3.0", "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", "ts-jest": "^29.1.2", @@ -397,17 +398,6 @@ "node": ">=6.0.0" } }, - "node_modules/@aw-web-design/x-default-browser": { - "version": "1.4.126", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser-id": "3.0.0" - }, - "bin": { - "x-default-browser": "bin/x-default-browser.js" - } - }, "node_modules/@azure/abort-controller": { "version": "2.1.2", "license": "MIT", @@ -2979,15 +2969,6 @@ "react": ">=18.0.0" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "devOptional": true, @@ -5299,11 +5280,6 @@ "node": ">=8" } }, - "node_modules/@fal-works/esbuild-plugin-global-externals": { - "version": "2.1.2", - "dev": true, - "license": "MIT" - }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz", @@ -6657,16 +6633,6 @@ "@types/react-native": "*" } }, - "node_modules/@ndelangen/get-tarball": { - "version": "3.0.9", - "dev": true, - "license": "MIT", - "dependencies": { - "gunzip-maybe": "^1.4.2", - "pump": "^3.0.0", - "tar-fs": "^2.1.1" - } - }, "node_modules/@ngneat/falso": { "version": "7.1.1", "dev": true, @@ -7063,14 +7029,6 @@ "perf-profiler-commands": "dist/src/commands.js" } }, - "node_modules/@perf-profiler/android/node_modules/commander": { - "version": "12.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@perf-profiler/ios": { "version": "0.3.3", "dev": true, @@ -7096,14 +7054,6 @@ "flashlight-ios-poc": "dist/launchIOS.js" } }, - "node_modules/@perf-profiler/ios-instruments/node_modules/commander": { - "version": "12.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@perf-profiler/logger": { "version": "0.3.3", "dev": true, @@ -10204,8 +10154,9 @@ } }, "node_modules/@rnmapbox/maps": { - "version": "10.1.26", - "license": "MIT", + "version": "10.1.30", + "resolved": "https://registry.npmjs.org/@rnmapbox/maps/-/maps-10.1.30.tgz", + "integrity": "sha512-3yl043+mpBldIHxTMMBU6Rdka6IjSww3kaIngltsUBTtnQI9NE1Yv3msC1X10E5bcfLHrhLxkiMSRhckCKBkPA==", "dependencies": { "@turf/along": "6.5.0", "@turf/distance": "6.5.0", @@ -10309,6 +10260,8 @@ }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", "dev": true, "license": "MIT", "engines": { @@ -11884,185 +11837,167 @@ "which-typed-array": "^1.1.2" } }, - "node_modules/@storybook/builder-manager": { - "version": "8.1.10", + "node_modules/@storybook/builder-webpack5": { + "version": "8.1.6", "dev": true, "license": "MIT", "dependencies": { - "@fal-works/esbuild-plugin-global-externals": "^2.1.2", - "@storybook/core-common": "8.1.10", - "@storybook/manager": "8.1.10", - "@storybook/node-logger": "8.1.10", - "@types/ejs": "^3.1.1", - "@yarnpkg/esbuild-plugin-pnp": "^3.0.0-rc.10", + "@storybook/channels": "8.1.6", + "@storybook/client-logger": "8.1.6", + "@storybook/core-common": "8.1.6", + "@storybook/core-events": "8.1.6", + "@storybook/core-webpack": "8.1.6", + "@storybook/node-logger": "8.1.6", + "@storybook/preview": "8.1.6", + "@storybook/preview-api": "8.1.6", + "@types/node": "^18.0.0", + "@types/semver": "^7.3.4", "browser-assert": "^1.2.1", - "ejs": "^3.1.10", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0", - "esbuild-plugin-alias": "^0.2.1", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "cjs-module-lexer": "^1.2.3", + "constants-browserify": "^1.0.0", + "css-loader": "^6.7.1", + "es-module-lexer": "^1.5.0", "express": "^4.17.3", + "fork-ts-checker-webpack-plugin": "^8.0.0", "fs-extra": "^11.1.0", + "html-webpack-plugin": "^5.5.0", + "magic-string": "^0.30.5", + "path-browserify": "^1.0.1", "process": "^0.11.10", - "util": "^0.12.4" + "semver": "^7.3.7", + "style-loader": "^3.3.1", + "terser-webpack-plugin": "^5.3.1", + "ts-dedent": "^2.0.0", + "url": "^0.11.0", + "util": "^0.12.4", + "util-deprecate": "^1.0.2", + "webpack": "5", + "webpack-dev-middleware": "^6.1.2", + "webpack-hot-middleware": "^2.25.1", + "webpack-virtual-modules": "^0.5.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@storybook/builder-manager/node_modules/@babel/traverse": { - "version": "7.24.7", + "node_modules/@storybook/builder-webpack5/node_modules/@types/node": { + "version": "18.19.34", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" + "undici-types": "~5.26.4" } }, - "node_modules/@storybook/builder-manager/node_modules/@storybook/channels": { - "version": "8.1.10", + "node_modules/@storybook/builder-webpack5/node_modules/fs-extra": { + "version": "11.2.0", "dev": true, "license": "MIT", "dependencies": { - "@storybook/client-logger": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/global": "^5.0.0", - "telejson": "^7.2.0", - "tiny-invariant": "^1.3.1" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" + "engines": { + "node": ">=14.14" } }, - "node_modules/@storybook/builder-manager/node_modules/@storybook/client-logger": { - "version": "8.1.10", + "node_modules/@storybook/builder-webpack5/node_modules/path-browserify": { + "version": "1.0.1", "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } + "license": "MIT" }, - "node_modules/@storybook/builder-manager/node_modules/@storybook/core-common": { - "version": "8.1.10", + "node_modules/@storybook/builder-webpack5/node_modules/style-loader": { + "version": "3.3.4", "dev": true, "license": "MIT", - "dependencies": { - "@storybook/core-events": "8.1.10", - "@storybook/csf-tools": "8.1.10", - "@storybook/node-logger": "8.1.10", - "@storybook/types": "8.1.10", - "@yarnpkg/fslib": "2.10.3", - "@yarnpkg/libzip": "2.3.0", - "chalk": "^4.1.0", - "cross-spawn": "^7.0.3", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0", - "esbuild-register": "^3.5.0", - "execa": "^5.0.0", - "file-system-cache": "2.3.0", - "find-cache-dir": "^3.0.0", - "find-up": "^5.0.0", - "fs-extra": "^11.1.0", - "glob": "^10.0.0", - "handlebars": "^4.7.7", - "lazy-universal-dotenv": "^4.0.0", - "node-fetch": "^2.0.0", - "picomatch": "^2.3.0", - "pkg-dir": "^5.0.0", - "prettier-fallback": "npm:prettier@^3", - "pretty-hrtime": "^1.0.3", - "resolve-from": "^5.0.0", - "semver": "^7.3.7", - "tempy": "^3.1.0", - "tiny-invariant": "^1.3.1", - "ts-dedent": "^2.0.0", - "util": "^0.12.4" + "engines": { + "node": ">= 12.13.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/storybook" + "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "prettier": "^2 || ^3" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - } + "webpack": "^5.0.0" } }, - "node_modules/@storybook/builder-manager/node_modules/@storybook/core-events": { - "version": "8.1.10", + "node_modules/@storybook/builder-webpack5/node_modules/util": { + "version": "0.12.5", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf": "^0.1.7", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" } }, - "node_modules/@storybook/builder-manager/node_modules/@storybook/csf-tools": { - "version": "8.1.10", + "node_modules/@storybook/channels": { + "version": "8.1.6", "dev": true, "license": "MIT", "dependencies": { - "@babel/generator": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", - "@storybook/csf": "^0.1.7", - "@storybook/types": "8.1.10", - "fs-extra": "^11.1.0", - "recast": "^0.23.5", - "ts-dedent": "^2.0.0" + "@storybook/client-logger": "8.1.6", + "@storybook/core-events": "8.1.6", + "@storybook/global": "^5.0.0", + "telejson": "^7.2.0", + "tiny-invariant": "^1.3.1" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/builder-manager/node_modules/@storybook/node-logger": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/builder-manager/node_modules/@storybook/types": { - "version": "8.1.10", + "node_modules/@storybook/cli": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@storybook/cli/-/cli-8.3.0.tgz", + "integrity": "sha512-kR2x43BU/keIUPr+jHXK16BkhUXk+t4I6DgYgKyjYfFpjX2+tNYZ2b1f7RW+TjjUy4V6cf9FXl5N+GFmih8oiQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/channels": "8.1.10", - "@types/express": "^4.7.0", - "file-system-cache": "2.3.0" + "@babel/core": "^7.24.4", + "@babel/types": "^7.24.0", + "@storybook/codemod": "8.3.0", + "@types/semver": "^7.3.4", + "chalk": "^4.1.0", + "commander": "^12.1.0", + "create-storybook": "8.3.0", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fd-package-json": "^1.2.0", + "find-up": "^5.0.0", + "fs-extra": "^11.1.0", + "giget": "^1.0.0", + "glob": "^10.0.0", + "globby": "^14.0.1", + "jscodeshift": "^0.15.1", + "leven": "^3.1.0", + "prompts": "^2.4.0", + "semver": "^7.3.7", + "storybook": "8.3.0", + "tiny-invariant": "^1.3.1", + "ts-dedent": "^2.0.0" + }, + "bin": { + "cli": "bin/index.cjs" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/builder-manager/node_modules/ansi-styles": { + "node_modules/@storybook/cli/node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -12075,16 +12010,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@storybook/builder-manager/node_modules/brace-expansion": { + "node_modules/@storybook/cli/node_modules/brace-expansion": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/@storybook/builder-manager/node_modules/chalk": { + "node_modules/@storybook/cli/node_modules/chalk": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -12098,8 +12037,10 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@storybook/builder-manager/node_modules/color-convert": { + "node_modules/@storybook/cli/node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12109,77 +12050,17 @@ "node": ">=7.0.0" } }, - "node_modules/@storybook/builder-manager/node_modules/color-name": { + "node_modules/@storybook/cli/node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, - "node_modules/@storybook/builder-manager/node_modules/crypto-random-string": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/builder-manager/node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/builder-manager/node_modules/find-cache-dir": { - "version": "3.3.2", - "dev": true, - "license": "MIT", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, - "node_modules/@storybook/builder-manager/node_modules/find-cache-dir/node_modules/find-up": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/builder-manager/node_modules/find-cache-dir/node_modules/pkg-dir": { - "version": "4.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/builder-manager/node_modules/fs-extra": { + "node_modules/@storybook/cli/node_modules/fs-extra": { "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", "dev": true, "license": "MIT", "dependencies": { @@ -12191,8 +12072,10 @@ "node": ">=14.14" } }, - "node_modules/@storybook/builder-manager/node_modules/glob": { - "version": "10.4.2", + "node_modules/@storybook/cli/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, "license": "ISC", "dependencies": { @@ -12206,42 +12089,50 @@ "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@storybook/builder-manager/node_modules/has-flag": { - "version": "4.0.0", + "node_modules/@storybook/cli/node_modules/globby": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", + "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", "dev": true, "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/builder-manager/node_modules/is-stream": { - "version": "3.0.0", + "node_modules/@storybook/cli/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/@storybook/builder-manager/node_modules/jackspeak": { - "version": "3.4.0", + "node_modules/@storybook/cli/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": ">=14" - }, "funding": { "url": "https://github.com/sponsors/isaacs" }, @@ -12249,41 +12140,50 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/@storybook/builder-manager/node_modules/locate-path": { - "version": "5.0.0", + "node_modules/@storybook/cli/node_modules/jscodeshift": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-0.15.2.tgz", + "integrity": "sha512-FquR7Okgmc4Sd0aEDwqho3rEiKR3BdvuG9jfdHjLJ6JQoWSMpavug3AoIfnfWhxFlf+5pzQh8qjqz0DWFrNQzA==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "@babel/core": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/plugin-transform-class-properties": "^7.22.5", + "@babel/plugin-transform-modules-commonjs": "^7.23.0", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.11", + "@babel/plugin-transform-optional-chaining": "^7.23.0", + "@babel/plugin-transform-private-methods": "^7.22.5", + "@babel/preset-flow": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@babel/register": "^7.22.15", + "babel-core": "^7.0.0-bridge.0", + "chalk": "^4.1.2", + "flow-parser": "0.*", + "graceful-fs": "^4.2.4", + "micromatch": "^4.0.4", + "neo-async": "^2.5.0", + "node-dir": "^0.1.17", + "recast": "^0.23.3", + "temp": "^0.8.4", + "write-file-atomic": "^2.3.0" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/builder-manager/node_modules/make-dir": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" + "bin": { + "jscodeshift": "bin/jscodeshift.js" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "@babel/preset-env": "^7.1.6" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/builder-manager/node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "peerDependenciesMeta": { + "@babel/preset-env": { + "optional": true + } } }, - "node_modules/@storybook/builder-manager/node_modules/minimatch": { - "version": "9.0.4", + "node_modules/@storybook/cli/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { @@ -12296,49 +12196,33 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@storybook/builder-manager/node_modules/minipass": { + "node_modules/@storybook/cli/node_modules/minipass": { "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } }, - "node_modules/@storybook/builder-manager/node_modules/p-limit": { - "version": "2.3.0", + "node_modules/@storybook/cli/node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", "dev": true, "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, "engines": { - "node": ">=6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/builder-manager/node_modules/p-locate": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/builder-manager/node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/builder-manager/node_modules/recast": { + "node_modules/@storybook/cli/node_modules/recast": { "version": "0.23.9", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.9.tgz", + "integrity": "sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==", "dev": true, "license": "MIT", "dependencies": { @@ -12352,16 +12236,33 @@ "node": ">= 4" } }, - "node_modules/@storybook/builder-manager/node_modules/source-map": { + "node_modules/@storybook/cli/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/cli/node_modules/source-map": { "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/@storybook/builder-manager/node_modules/supports-color": { + "node_modules/@storybook/cli/node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -12371,178 +12272,282 @@ "node": ">=8" } }, - "node_modules/@storybook/builder-manager/node_modules/temp-dir": { - "version": "3.0.0", + "node_modules/@storybook/client-logger": { + "version": "8.1.6", "dev": true, "license": "MIT", - "engines": { - "node": ">=14.16" + "dependencies": { + "@storybook/global": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/builder-manager/node_modules/tempy": { - "version": "3.1.0", + "node_modules/@storybook/codemod": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@storybook/codemod/-/codemod-8.3.0.tgz", + "integrity": "sha512-WwHgQLJw02eflkAzkUfuNP8Hu7Z12E6diUN2AWDXVYZJXyJjYhivGzONt2inrHhT3LTB9iSNVo0WsDE9AZU9RA==", "dev": true, "license": "MIT", "dependencies": { - "is-stream": "^3.0.0", - "temp-dir": "^3.0.0", - "type-fest": "^2.12.2", - "unique-string": "^3.0.0" - }, - "engines": { - "node": ">=14.16" + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.24.4", + "@babel/types": "^7.24.0", + "@storybook/core": "8.3.0", + "@storybook/csf": "^0.1.11", + "@types/cross-spawn": "^6.0.2", + "cross-spawn": "^7.0.3", + "globby": "^14.0.1", + "jscodeshift": "^0.15.1", + "lodash": "^4.17.21", + "prettier": "^3.1.1", + "recast": "^0.23.5", + "tiny-invariant": "^1.3.1" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/builder-manager/node_modules/type-fest": { - "version": "2.19.0", + "node_modules/@storybook/codemod/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "(MIT OR CC0-1.0)", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=12.20" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@storybook/builder-manager/node_modules/unique-string": { - "version": "3.0.0", + "node_modules/@storybook/codemod/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { - "crypto-random-string": "^4.0.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@storybook/builder-manager/node_modules/util": { - "version": "0.12.5", + "node_modules/@storybook/codemod/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, - "node_modules/@storybook/builder-webpack5": { - "version": "8.1.6", + "node_modules/@storybook/codemod/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/codemod/node_modules/globby": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", + "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/channels": "8.1.6", - "@storybook/client-logger": "8.1.6", - "@storybook/core-common": "8.1.6", - "@storybook/core-events": "8.1.6", - "@storybook/core-webpack": "8.1.6", - "@storybook/node-logger": "8.1.6", - "@storybook/preview": "8.1.6", - "@storybook/preview-api": "8.1.6", - "@types/node": "^18.0.0", - "@types/semver": "^7.3.4", - "browser-assert": "^1.2.1", - "case-sensitive-paths-webpack-plugin": "^2.4.0", - "cjs-module-lexer": "^1.2.3", - "constants-browserify": "^1.0.0", - "css-loader": "^6.7.1", - "es-module-lexer": "^1.5.0", - "express": "^4.17.3", - "fork-ts-checker-webpack-plugin": "^8.0.0", - "fs-extra": "^11.1.0", - "html-webpack-plugin": "^5.5.0", - "magic-string": "^0.30.5", - "path-browserify": "^1.0.1", - "process": "^0.11.10", - "semver": "^7.3.7", - "style-loader": "^3.3.1", - "terser-webpack-plugin": "^5.3.1", - "ts-dedent": "^2.0.0", - "url": "^0.11.0", - "util": "^0.12.4", - "util-deprecate": "^1.0.2", - "webpack": "5", - "webpack-dev-middleware": "^6.1.2", - "webpack-hot-middleware": "^2.25.1", - "webpack-virtual-modules": "^0.5.0" + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/codemod/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@storybook/codemod/node_modules/jscodeshift": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-0.15.2.tgz", + "integrity": "sha512-FquR7Okgmc4Sd0aEDwqho3rEiKR3BdvuG9jfdHjLJ6JQoWSMpavug3AoIfnfWhxFlf+5pzQh8qjqz0DWFrNQzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/plugin-transform-class-properties": "^7.22.5", + "@babel/plugin-transform-modules-commonjs": "^7.23.0", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.11", + "@babel/plugin-transform-optional-chaining": "^7.23.0", + "@babel/plugin-transform-private-methods": "^7.22.5", + "@babel/preset-flow": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@babel/register": "^7.22.15", + "babel-core": "^7.0.0-bridge.0", + "chalk": "^4.1.2", + "flow-parser": "0.*", + "graceful-fs": "^4.2.4", + "micromatch": "^4.0.4", + "neo-async": "^2.5.0", + "node-dir": "^0.1.17", + "recast": "^0.23.3", + "temp": "^0.8.4", + "write-file-atomic": "^2.3.0" + }, + "bin": { + "jscodeshift": "bin/jscodeshift.js" + }, + "peerDependencies": { + "@babel/preset-env": "^7.1.6" }, "peerDependenciesMeta": { - "typescript": { + "@babel/preset-env": { "optional": true } } }, - "node_modules/@storybook/builder-webpack5/node_modules/@types/node": { - "version": "18.19.34", + "node_modules/@storybook/codemod/node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/builder-webpack5/node_modules/fs-extra": { - "version": "11.2.0", + "node_modules/@storybook/codemod/node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "bin": { + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=14.14" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/@storybook/builder-webpack5/node_modules/path-browserify": { - "version": "1.0.1", + "node_modules/@storybook/codemod/node_modules/recast": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.9.tgz", + "integrity": "sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } }, - "node_modules/@storybook/builder-webpack5/node_modules/style-loader": { - "version": "3.3.4", + "node_modules/@storybook/codemod/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 12.13.0" + "node": ">=14.16" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/codemod/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@storybook/codemod/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" }, - "peerDependencies": { - "webpack": "^5.0.0" + "engines": { + "node": ">=8" } }, - "node_modules/@storybook/builder-webpack5/node_modules/util": { - "version": "0.12.5", + "node_modules/@storybook/components": { + "version": "8.1.10", "dev": true, "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-slot": "^1.0.2", + "@storybook/client-logger": "8.1.10", + "@storybook/csf": "^0.1.7", + "@storybook/global": "^5.0.0", + "@storybook/icons": "^1.2.5", + "@storybook/theming": "8.1.10", + "@storybook/types": "8.1.10", + "memoizerific": "^1.11.3", + "util-deprecate": "^1.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" } }, - "node_modules/@storybook/channels": { - "version": "8.1.6", + "node_modules/@storybook/components/node_modules/@storybook/channels": { + "version": "8.1.10", "dev": true, "license": "MIT", "dependencies": { - "@storybook/client-logger": "8.1.6", - "@storybook/core-events": "8.1.6", + "@storybook/client-logger": "8.1.10", + "@storybook/core-events": "8.1.10", "@storybook/global": "^5.0.0", "telejson": "^7.2.0", "tiny-invariant": "^1.3.1" @@ -12552,114 +12557,78 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/cli": { + "node_modules/@storybook/components/node_modules/@storybook/client-logger": { "version": "8.1.10", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.24.4", - "@babel/types": "^7.24.0", - "@ndelangen/get-tarball": "^3.0.7", - "@storybook/codemod": "8.1.10", - "@storybook/core-common": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/core-server": "8.1.10", - "@storybook/csf-tools": "8.1.10", - "@storybook/node-logger": "8.1.10", - "@storybook/telemetry": "8.1.10", - "@storybook/types": "8.1.10", - "@types/semver": "^7.3.4", - "@yarnpkg/fslib": "2.10.3", - "@yarnpkg/libzip": "2.3.0", - "chalk": "^4.1.0", - "commander": "^6.2.1", - "cross-spawn": "^7.0.3", - "detect-indent": "^6.1.0", - "envinfo": "^7.7.3", - "execa": "^5.0.0", - "find-up": "^5.0.0", - "fs-extra": "^11.1.0", - "get-npm-tarball-url": "^2.0.3", - "giget": "^1.0.0", - "globby": "^14.0.1", - "jscodeshift": "^0.15.1", - "leven": "^3.1.0", - "ora": "^5.4.1", - "prettier": "^3.1.1", - "prompts": "^2.4.0", - "read-pkg-up": "^7.0.1", - "semver": "^7.3.7", - "strip-json-comments": "^3.0.1", - "tempy": "^3.1.0", - "tiny-invariant": "^1.3.1", - "ts-dedent": "^2.0.0" - }, - "bin": { - "getstorybook": "bin/index.js", - "sb": "bin/index.js" + "@storybook/global": "^5.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/cli/node_modules/@babel/traverse": { - "version": "7.24.7", + "node_modules/@storybook/components/node_modules/@storybook/core-events": { + "version": "8.1.10", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@storybook/csf": "^0.1.7", + "ts-dedent": "^2.0.0" }, - "engines": { - "node": ">=6.9.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/cli/node_modules/@storybook/channels": { + "node_modules/@storybook/components/node_modules/@storybook/types": { "version": "8.1.10", "dev": true, "license": "MIT", "dependencies": { - "@storybook/client-logger": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/global": "^5.0.0", - "telejson": "^7.2.0", - "tiny-invariant": "^1.3.1" + "@storybook/channels": "8.1.10", + "@types/express": "^4.7.0", + "file-system-cache": "2.3.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/cli/node_modules/@storybook/client-logger": { - "version": "8.1.10", + "node_modules/@storybook/core": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.3.0.tgz", + "integrity": "sha512-UeErpD0xRIP2nFA2TjPYxtEyv24O6VRfq2XXU5ki2QPYnxOxAPBbrMHCADjgBwNS4S2NUWTaVBYxybISVbrj+w==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/global": "^5.0.0" + "@storybook/csf": "^0.1.11", + "@types/express": "^4.17.21", + "browser-assert": "^1.2.1", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0", + "esbuild-register": "^3.5.0", + "express": "^4.19.2", + "process": "^0.11.10", + "recast": "^0.23.5", + "semver": "^7.6.2", + "util": "^0.12.5", + "ws": "^8.2.3" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/cli/node_modules/@storybook/core-common": { - "version": "8.1.10", + "node_modules/@storybook/core-common": { + "version": "8.1.6", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core-events": "8.1.10", - "@storybook/csf-tools": "8.1.10", - "@storybook/node-logger": "8.1.10", - "@storybook/types": "8.1.10", + "@storybook/core-events": "8.1.6", + "@storybook/csf-tools": "8.1.6", + "@storybook/node-logger": "8.1.6", + "@storybook/types": "8.1.6", "@yarnpkg/fslib": "2.10.3", "@yarnpkg/libzip": "2.3.0", "chalk": "^4.1.0", @@ -12699,63 +12668,7 @@ } } }, - "node_modules/@storybook/cli/node_modules/@storybook/core-events": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/csf": "^0.1.7", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/cli/node_modules/@storybook/csf-tools": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", - "@storybook/csf": "^0.1.7", - "@storybook/types": "8.1.10", - "fs-extra": "^11.1.0", - "recast": "^0.23.5", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/cli/node_modules/@storybook/node-logger": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/cli/node_modules/@storybook/types": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/channels": "8.1.10", - "@types/express": "^4.7.0", - "file-system-cache": "2.3.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/cli/node_modules/ansi-styles": { + "node_modules/@storybook/core-common/node_modules/ansi-styles": { "version": "4.3.0", "dev": true, "license": "MIT", @@ -12769,7 +12682,7 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@storybook/cli/node_modules/brace-expansion": { + "node_modules/@storybook/core-common/node_modules/brace-expansion": { "version": "2.0.1", "dev": true, "license": "MIT", @@ -12777,7 +12690,7 @@ "balanced-match": "^1.0.0" } }, - "node_modules/@storybook/cli/node_modules/chalk": { + "node_modules/@storybook/core-common/node_modules/chalk": { "version": "4.1.2", "dev": true, "license": "MIT", @@ -12792,7 +12705,7 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@storybook/cli/node_modules/color-convert": { + "node_modules/@storybook/core-common/node_modules/color-convert": { "version": "2.0.1", "dev": true, "license": "MIT", @@ -12803,12 +12716,12 @@ "node": ">=7.0.0" } }, - "node_modules/@storybook/cli/node_modules/color-name": { + "node_modules/@storybook/core-common/node_modules/color-name": { "version": "1.1.4", "dev": true, "license": "MIT" }, - "node_modules/@storybook/cli/node_modules/crypto-random-string": { + "node_modules/@storybook/core-common/node_modules/crypto-random-string": { "version": "4.0.0", "dev": true, "license": "MIT", @@ -12822,7 +12735,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/cli/node_modules/crypto-random-string/node_modules/type-fest": { + "node_modules/@storybook/core-common/node_modules/crypto-random-string/node_modules/type-fest": { "version": "1.4.0", "dev": true, "license": "(MIT OR CC0-1.0)", @@ -12833,7 +12746,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/cli/node_modules/find-cache-dir": { + "node_modules/@storybook/core-common/node_modules/find-cache-dir": { "version": "3.3.2", "dev": true, "license": "MIT", @@ -12849,7 +12762,7 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, - "node_modules/@storybook/cli/node_modules/find-cache-dir/node_modules/find-up": { + "node_modules/@storybook/core-common/node_modules/find-cache-dir/node_modules/find-up": { "version": "4.1.0", "dev": true, "license": "MIT", @@ -12861,7 +12774,7 @@ "node": ">=8" } }, - "node_modules/@storybook/cli/node_modules/find-cache-dir/node_modules/pkg-dir": { + "node_modules/@storybook/core-common/node_modules/find-cache-dir/node_modules/pkg-dir": { "version": "4.2.0", "dev": true, "license": "MIT", @@ -12872,7 +12785,7 @@ "node": ">=8" } }, - "node_modules/@storybook/cli/node_modules/fs-extra": { + "node_modules/@storybook/core-common/node_modules/fs-extra": { "version": "11.2.0", "dev": true, "license": "MIT", @@ -12885,48 +12798,28 @@ "node": ">=14.14" } }, - "node_modules/@storybook/cli/node_modules/glob": { - "version": "10.4.2", + "node_modules/@storybook/core-common/node_modules/glob": { + "version": "10.3.12", "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "jackspeak": "^2.3.6", + "minimatch": "^9.0.1", + "minipass": "^7.0.4", + "path-scurry": "^1.10.2" }, "bin": { "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@storybook/cli/node_modules/globby": { - "version": "14.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.2", - "ignore": "^5.2.4", - "path-type": "^5.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/cli/node_modules/has-flag": { + "node_modules/@storybook/core-common/node_modules/has-flag": { "version": "4.0.0", "dev": true, "license": "MIT", @@ -12934,7 +12827,7 @@ "node": ">=8" } }, - "node_modules/@storybook/cli/node_modules/is-stream": { + "node_modules/@storybook/core-common/node_modules/is-stream": { "version": "3.0.0", "dev": true, "license": "MIT", @@ -12945,62 +12838,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/cli/node_modules/jackspeak": { - "version": "3.4.0", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/@storybook/cli/node_modules/jscodeshift": { - "version": "0.15.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.23.0", - "@babel/parser": "^7.23.0", - "@babel/plugin-transform-class-properties": "^7.22.5", - "@babel/plugin-transform-modules-commonjs": "^7.23.0", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.11", - "@babel/plugin-transform-optional-chaining": "^7.23.0", - "@babel/plugin-transform-private-methods": "^7.22.5", - "@babel/preset-flow": "^7.22.15", - "@babel/preset-typescript": "^7.23.0", - "@babel/register": "^7.22.15", - "babel-core": "^7.0.0-bridge.0", - "chalk": "^4.1.2", - "flow-parser": "0.*", - "graceful-fs": "^4.2.4", - "micromatch": "^4.0.4", - "neo-async": "^2.5.0", - "node-dir": "^0.1.17", - "recast": "^0.23.3", - "temp": "^0.8.4", - "write-file-atomic": "^2.3.0" - }, - "bin": { - "jscodeshift": "bin/jscodeshift.js" - }, - "peerDependencies": { - "@babel/preset-env": "^7.1.6" - }, - "peerDependenciesMeta": { - "@babel/preset-env": { - "optional": true - } - } - }, - "node_modules/@storybook/cli/node_modules/locate-path": { + "node_modules/@storybook/core-common/node_modules/locate-path": { "version": "5.0.0", "dev": true, "license": "MIT", @@ -13011,7 +12849,7 @@ "node": ">=8" } }, - "node_modules/@storybook/cli/node_modules/make-dir": { + "node_modules/@storybook/core-common/node_modules/make-dir": { "version": "3.1.0", "dev": true, "license": "MIT", @@ -13025,7 +12863,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/cli/node_modules/make-dir/node_modules/semver": { + "node_modules/@storybook/core-common/node_modules/make-dir/node_modules/semver": { "version": "6.3.1", "dev": true, "license": "ISC", @@ -13033,7 +12871,7 @@ "semver": "bin/semver.js" } }, - "node_modules/@storybook/cli/node_modules/minimatch": { + "node_modules/@storybook/core-common/node_modules/minimatch": { "version": "9.0.4", "dev": true, "license": "ISC", @@ -13047,7 +12885,7 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@storybook/cli/node_modules/minipass": { + "node_modules/@storybook/core-common/node_modules/minipass": { "version": "7.1.2", "dev": true, "license": "ISC", @@ -13055,7 +12893,7 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/@storybook/cli/node_modules/p-limit": { + "node_modules/@storybook/core-common/node_modules/p-limit": { "version": "2.3.0", "dev": true, "license": "MIT", @@ -13069,7 +12907,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/cli/node_modules/p-locate": { + "node_modules/@storybook/core-common/node_modules/p-locate": { "version": "4.1.0", "dev": true, "license": "MIT", @@ -13080,7 +12918,7 @@ "node": ">=8" } }, - "node_modules/@storybook/cli/node_modules/path-exists": { + "node_modules/@storybook/core-common/node_modules/path-exists": { "version": "4.0.0", "dev": true, "license": "MIT", @@ -13088,50 +12926,35 @@ "node": ">=8" } }, - "node_modules/@storybook/cli/node_modules/path-type": { - "version": "5.0.0", + "node_modules/@storybook/core-common/node_modules/supports-color": { + "version": "7.2.0", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "has-flag": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, - "node_modules/@storybook/cli/node_modules/prettier": { - "version": "3.3.2", + "node_modules/@storybook/core-common/node_modules/temp-dir": { + "version": "3.0.0", "dev": true, "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "node": ">=14.16" } }, - "node_modules/@storybook/cli/node_modules/recast": { - "version": "0.23.9", + "node_modules/@storybook/core-common/node_modules/tempy": { + "version": "3.1.0", "dev": true, "license": "MIT", "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/@storybook/cli/node_modules/slash": { - "version": "5.1.0", - "dev": true, - "license": "MIT", "engines": { "node": ">=14.16" }, @@ -13139,77 +12962,111 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/cli/node_modules/source-map": { - "version": "0.6.1", + "node_modules/@storybook/core-common/node_modules/type-fest": { + "version": "2.19.0", "dev": true, - "license": "BSD-3-Clause", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=0.10.0" + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/cli/node_modules/supports-color": { - "version": "7.2.0", + "node_modules/@storybook/core-common/node_modules/unique-string": { + "version": "3.0.0", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "crypto-random-string": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/cli/node_modules/temp-dir": { - "version": "3.0.0", + "node_modules/@storybook/core-common/node_modules/util": { + "version": "0.12.5", "dev": true, "license": "MIT", - "engines": { - "node": ">=14.16" + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" } }, - "node_modules/@storybook/cli/node_modules/tempy": { - "version": "3.1.0", + "node_modules/@storybook/core-events": { + "version": "8.1.6", "dev": true, "license": "MIT", "dependencies": { - "is-stream": "^3.0.0", - "temp-dir": "^3.0.0", - "type-fest": "^2.12.2", - "unique-string": "^3.0.0" - }, - "engines": { - "node": ">=14.16" + "@storybook/csf": "^0.1.7", + "ts-dedent": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/cli/node_modules/type-fest": { - "version": "2.19.0", + "node_modules/@storybook/core-webpack": { + "version": "8.1.6", "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" + "license": "MIT", + "dependencies": { + "@storybook/core-common": "8.1.6", + "@storybook/node-logger": "8.1.6", + "@storybook/types": "8.1.6", + "@types/node": "^18.0.0", + "ts-dedent": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/cli/node_modules/unique-string": { - "version": "3.0.0", + "node_modules/@storybook/core-webpack/node_modules/@types/node": { + "version": "18.19.34", "dev": true, "license": "MIT", "dependencies": { - "crypto-random-string": "^4.0.0" + "undici-types": "~5.26.4" + } + }, + "node_modules/@storybook/core/node_modules/recast": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.9.tgz", + "integrity": "sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 4" + } + }, + "node_modules/@storybook/core/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/@storybook/cli/node_modules/util": { + "node_modules/@storybook/core/node_modules/util": { "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", "dev": true, "license": "MIT", "dependencies": { @@ -13220,45 +13077,30 @@ "which-typed-array": "^1.1.2" } }, - "node_modules/@storybook/client-logger": { - "version": "8.1.6", + "node_modules/@storybook/csf": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.11.tgz", + "integrity": "sha512-dHYFQH3mA+EtnCkHXzicbLgsvzYjcDJ1JWsogbItZogkPHgSJM/Wr71uMkcvw8v9mmCyP4NpXJuu6bPoVsOnzg==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/global": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" + "type-fest": "^2.19.0" } }, - "node_modules/@storybook/codemod": { + "node_modules/@storybook/csf-plugin": { "version": "8.1.10", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.24.4", - "@babel/preset-env": "^7.24.4", - "@babel/types": "^7.24.0", - "@storybook/csf": "^0.1.7", "@storybook/csf-tools": "8.1.10", - "@storybook/node-logger": "8.1.10", - "@storybook/types": "8.1.10", - "@types/cross-spawn": "^6.0.2", - "cross-spawn": "^7.0.3", - "globby": "^14.0.1", - "jscodeshift": "^0.15.1", - "lodash": "^4.17.21", - "prettier": "^3.1.1", - "recast": "^0.23.5", - "tiny-invariant": "^1.3.1" + "unplugin": "^1.3.1" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/codemod/node_modules/@babel/traverse": { + "node_modules/@storybook/csf-plugin/node_modules/@babel/traverse": { "version": "7.24.7", "dev": true, "license": "MIT", @@ -13278,7 +13120,7 @@ "node": ">=6.9.0" } }, - "node_modules/@storybook/codemod/node_modules/@storybook/channels": { + "node_modules/@storybook/csf-plugin/node_modules/@storybook/channels": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -13294,7 +13136,7 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/codemod/node_modules/@storybook/client-logger": { + "node_modules/@storybook/csf-plugin/node_modules/@storybook/client-logger": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -13306,7 +13148,7 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/codemod/node_modules/@storybook/core-events": { + "node_modules/@storybook/csf-plugin/node_modules/@storybook/core-events": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -13319,7 +13161,7 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/codemod/node_modules/@storybook/csf-tools": { + "node_modules/@storybook/csf-plugin/node_modules/@storybook/csf-tools": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -13339,16 +13181,7 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/codemod/node_modules/@storybook/node-logger": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/codemod/node_modules/@storybook/types": { + "node_modules/@storybook/csf-plugin/node_modules/@storybook/types": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -13362,52 +13195,83 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/codemod/node_modules/ansi-styles": { - "version": "4.3.0", + "node_modules/@storybook/csf-plugin/node_modules/fs-extra": { + "version": "11.2.0", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=14.14" } }, - "node_modules/@storybook/codemod/node_modules/chalk": { - "version": "4.1.2", + "node_modules/@storybook/csf-plugin/node_modules/recast": { + "version": "0.23.9", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">= 4" + } + }, + "node_modules/@storybook/csf-plugin/node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@storybook/csf-tools": { + "version": "8.1.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.24.4", + "@babel/parser": "^7.24.4", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0", + "@storybook/csf": "^0.1.7", + "@storybook/types": "8.1.6", + "fs-extra": "^11.1.0", + "recast": "^0.23.5", + "ts-dedent": "^2.0.0" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/codemod/node_modules/color-convert": { - "version": "2.0.1", + "node_modules/@storybook/csf-tools/node_modules/@babel/traverse": { + "version": "7.24.7", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.24.7", + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-function-name": "^7.24.7", + "@babel/helper-hoist-variables": "^7.24.7", + "@babel/helper-split-export-declaration": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/types": "^7.24.7", + "debug": "^4.3.1", + "globals": "^11.1.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=6.9.0" } }, - "node_modules/@storybook/codemod/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/codemod/node_modules/fs-extra": { + "node_modules/@storybook/csf-tools/node_modules/fs-extra": { "version": "11.2.0", "dev": true, "license": "MIT", @@ -13420,167 +13284,127 @@ "node": ">=14.14" } }, - "node_modules/@storybook/codemod/node_modules/globby": { - "version": "14.0.1", + "node_modules/@storybook/csf-tools/node_modules/recast": { + "version": "0.23.9", "dev": true, "license": "MIT", "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.2", - "ignore": "^5.2.4", - "path-type": "^5.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.1.0" + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 4" } }, - "node_modules/@storybook/codemod/node_modules/has-flag": { - "version": "4.0.0", + "node_modules/@storybook/csf-tools/node_modules/source-map": { + "version": "0.6.1", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/codemod/node_modules/jscodeshift": { - "version": "0.15.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.23.0", - "@babel/parser": "^7.23.0", - "@babel/plugin-transform-class-properties": "^7.22.5", - "@babel/plugin-transform-modules-commonjs": "^7.23.0", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.11", - "@babel/plugin-transform-optional-chaining": "^7.23.0", - "@babel/plugin-transform-private-methods": "^7.22.5", - "@babel/preset-flow": "^7.22.15", - "@babel/preset-typescript": "^7.23.0", - "@babel/register": "^7.22.15", - "babel-core": "^7.0.0-bridge.0", - "chalk": "^4.1.2", - "flow-parser": "0.*", - "graceful-fs": "^4.2.4", - "micromatch": "^4.0.4", - "neo-async": "^2.5.0", - "node-dir": "^0.1.17", - "recast": "^0.23.3", - "temp": "^0.8.4", - "write-file-atomic": "^2.3.0" - }, - "bin": { - "jscodeshift": "bin/jscodeshift.js" - }, - "peerDependencies": { - "@babel/preset-env": "^7.1.6" - }, - "peerDependenciesMeta": { - "@babel/preset-env": { - "optional": true - } + "node": ">=0.10.0" } }, - "node_modules/@storybook/codemod/node_modules/path-type": { - "version": "5.0.0", + "node_modules/@storybook/csf/node_modules/type-fest": { + "version": "2.19.0", "dev": true, - "license": "MIT", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=12" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/codemod/node_modules/prettier": { - "version": "3.3.2", + "node_modules/@storybook/docs-tools": { + "version": "8.1.6", "dev": true, "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" + "dependencies": { + "@storybook/core-common": "8.1.6", + "@storybook/core-events": "8.1.6", + "@storybook/preview-api": "8.1.6", + "@storybook/types": "8.1.6", + "@types/doctrine": "^0.0.3", + "assert": "^2.1.0", + "doctrine": "^3.0.0", + "lodash": "^4.17.21" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/codemod/node_modules/recast": { - "version": "0.23.9", + "node_modules/@storybook/docs-tools/node_modules/assert": { + "version": "2.1.0", "dev": true, "license": "MIT", "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" } }, - "node_modules/@storybook/codemod/node_modules/slash": { - "version": "5.1.0", + "node_modules/@storybook/docs-tools/node_modules/util": { + "version": "0.12.5", "dev": true, "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" } }, - "node_modules/@storybook/codemod/node_modules/source-map": { - "version": "0.6.1", + "node_modules/@storybook/global": { + "version": "5.0.0", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, - "node_modules/@storybook/codemod/node_modules/supports-color": { - "version": "7.2.0", + "node_modules/@storybook/icons": { + "version": "1.2.9", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/@storybook/components": { + "node_modules/@storybook/manager-api": { "version": "8.1.10", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-slot": "^1.0.2", + "@storybook/channels": "8.1.10", "@storybook/client-logger": "8.1.10", + "@storybook/core-events": "8.1.10", "@storybook/csf": "^0.1.7", "@storybook/global": "^5.0.0", "@storybook/icons": "^1.2.5", + "@storybook/router": "8.1.10", "@storybook/theming": "8.1.10", "@storybook/types": "8.1.10", + "dequal": "^2.0.2", + "lodash": "^4.17.21", "memoizerific": "^1.11.3", - "util-deprecate": "^1.0.2" + "store2": "^2.14.2", + "telejson": "^7.2.0", + "ts-dedent": "^2.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" } }, - "node_modules/@storybook/components/node_modules/@storybook/channels": { + "node_modules/@storybook/manager-api/node_modules/@storybook/channels": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -13596,7 +13420,7 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/components/node_modules/@storybook/client-logger": { + "node_modules/@storybook/manager-api/node_modules/@storybook/client-logger": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -13608,7 +13432,7 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/components/node_modules/@storybook/core-events": { + "node_modules/@storybook/manager-api/node_modules/@storybook/core-events": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -13621,7 +13445,7 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/components/node_modules/@storybook/types": { + "node_modules/@storybook/manager-api/node_modules/@storybook/types": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -13635,2270 +13459,120 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/core-common": { + "node_modules/@storybook/node-logger": { "version": "8.1.6", "dev": true, "license": "MIT", - "dependencies": { - "@storybook/core-events": "8.1.6", - "@storybook/csf-tools": "8.1.6", - "@storybook/node-logger": "8.1.6", - "@storybook/types": "8.1.6", - "@yarnpkg/fslib": "2.10.3", - "@yarnpkg/libzip": "2.3.0", - "chalk": "^4.1.0", - "cross-spawn": "^7.0.3", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0", - "esbuild-register": "^3.5.0", - "execa": "^5.0.0", - "file-system-cache": "2.3.0", - "find-cache-dir": "^3.0.0", - "find-up": "^5.0.0", - "fs-extra": "^11.1.0", - "glob": "^10.0.0", - "handlebars": "^4.7.7", - "lazy-universal-dotenv": "^4.0.0", - "node-fetch": "^2.0.0", - "picomatch": "^2.3.0", - "pkg-dir": "^5.0.0", - "prettier-fallback": "npm:prettier@^3", - "pretty-hrtime": "^1.0.3", - "resolve-from": "^5.0.0", - "semver": "^7.3.7", - "tempy": "^3.1.0", - "tiny-invariant": "^1.3.1", - "ts-dedent": "^2.0.0", - "util": "^0.12.4" - }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "prettier": "^2 || ^3" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - } - } - }, - "node_modules/@storybook/core-common/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@storybook/core-common/node_modules/brace-expansion": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@storybook/core-common/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@storybook/core-common/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" } }, - "node_modules/@storybook/core-common/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/core-common/node_modules/crypto-random-string": { - "version": "4.0.0", + "node_modules/@storybook/preset-react-webpack": { + "version": "8.1.6", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-common/node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" + "@storybook/core-webpack": "8.1.6", + "@storybook/docs-tools": "8.1.6", + "@storybook/node-logger": "8.1.6", + "@storybook/react": "8.1.6", + "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0", + "@types/node": "^18.0.0", + "@types/semver": "^7.3.4", + "find-up": "^5.0.0", + "fs-extra": "^11.1.0", + "magic-string": "^0.30.5", + "react-docgen": "^7.0.0", + "resolve": "^1.22.8", + "semver": "^7.3.7", + "tsconfig-paths": "^4.2.0", + "webpack": "5" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-common/node_modules/find-cache-dir": { - "version": "3.3.2", - "dev": true, - "license": "MIT", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, - "node_modules/@storybook/core-common/node_modules/find-cache-dir/node_modules/find-up": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-common/node_modules/find-cache-dir/node_modules/pkg-dir": { - "version": "4.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-common/node_modules/fs-extra": { - "version": "11.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@storybook/core-common/node_modules/glob": { - "version": "10.3.12", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.6", - "minimatch": "^9.0.1", - "minipass": "^7.0.4", - "path-scurry": "^1.10.2" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@storybook/core-common/node_modules/has-flag": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-common/node_modules/is-stream": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-common/node_modules/locate-path": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-common/node_modules/make-dir": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-common/node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@storybook/core-common/node_modules/minimatch": { - "version": "9.0.4", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@storybook/core-common/node_modules/minipass": { - "version": "7.1.2", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/@storybook/core-common/node_modules/p-limit": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-common/node_modules/p-locate": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-common/node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-common/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-common/node_modules/temp-dir": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@storybook/core-common/node_modules/tempy": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "is-stream": "^3.0.0", - "temp-dir": "^3.0.0", - "type-fest": "^2.12.2", - "unique-string": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-common/node_modules/type-fest": { - "version": "2.19.0", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-common/node_modules/unique-string": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "crypto-random-string": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-common/node_modules/util": { - "version": "0.12.5", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, - "node_modules/@storybook/core-events": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/csf": "^0.1.7", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/core-server": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@aw-web-design/x-default-browser": "1.4.126", - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "@discoveryjs/json-ext": "^0.5.3", - "@storybook/builder-manager": "8.1.10", - "@storybook/channels": "8.1.10", - "@storybook/core-common": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/csf": "^0.1.7", - "@storybook/csf-tools": "8.1.10", - "@storybook/docs-mdx": "3.1.0-next.0", - "@storybook/global": "^5.0.0", - "@storybook/manager": "8.1.10", - "@storybook/manager-api": "8.1.10", - "@storybook/node-logger": "8.1.10", - "@storybook/preview-api": "8.1.10", - "@storybook/telemetry": "8.1.10", - "@storybook/types": "8.1.10", - "@types/detect-port": "^1.3.0", - "@types/diff": "^5.0.9", - "@types/node": "^18.0.0", - "@types/pretty-hrtime": "^1.0.0", - "@types/semver": "^7.3.4", - "better-opn": "^3.0.2", - "chalk": "^4.1.0", - "cli-table3": "^0.6.1", - "compression": "^1.7.4", - "detect-port": "^1.3.0", - "diff": "^5.2.0", - "express": "^4.17.3", - "fs-extra": "^11.1.0", - "globby": "^14.0.1", - "lodash": "^4.17.21", - "open": "^8.4.0", - "pretty-hrtime": "^1.0.3", - "prompts": "^2.4.0", - "read-pkg-up": "^7.0.1", - "semver": "^7.3.7", - "telejson": "^7.2.0", - "tiny-invariant": "^1.3.1", - "ts-dedent": "^2.0.0", - "util": "^0.12.4", - "util-deprecate": "^1.0.2", - "watchpack": "^2.2.0", - "ws": "^8.2.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/core-server/node_modules/@babel/traverse": { - "version": "7.24.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@storybook/core-server/node_modules/@storybook/channels": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/client-logger": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/global": "^5.0.0", - "telejson": "^7.2.0", - "tiny-invariant": "^1.3.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/core-server/node_modules/@storybook/client-logger": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/core-server/node_modules/@storybook/core-common": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/core-events": "8.1.10", - "@storybook/csf-tools": "8.1.10", - "@storybook/node-logger": "8.1.10", - "@storybook/types": "8.1.10", - "@yarnpkg/fslib": "2.10.3", - "@yarnpkg/libzip": "2.3.0", - "chalk": "^4.1.0", - "cross-spawn": "^7.0.3", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0", - "esbuild-register": "^3.5.0", - "execa": "^5.0.0", - "file-system-cache": "2.3.0", - "find-cache-dir": "^3.0.0", - "find-up": "^5.0.0", - "fs-extra": "^11.1.0", - "glob": "^10.0.0", - "handlebars": "^4.7.7", - "lazy-universal-dotenv": "^4.0.0", - "node-fetch": "^2.0.0", - "picomatch": "^2.3.0", - "pkg-dir": "^5.0.0", - "prettier-fallback": "npm:prettier@^3", - "pretty-hrtime": "^1.0.3", - "resolve-from": "^5.0.0", - "semver": "^7.3.7", - "tempy": "^3.1.0", - "tiny-invariant": "^1.3.1", - "ts-dedent": "^2.0.0", - "util": "^0.12.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "prettier": "^2 || ^3" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - } - } - }, - "node_modules/@storybook/core-server/node_modules/@storybook/core-events": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/csf": "^0.1.7", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/core-server/node_modules/@storybook/csf-tools": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", - "@storybook/csf": "^0.1.7", - "@storybook/types": "8.1.10", - "fs-extra": "^11.1.0", - "recast": "^0.23.5", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/core-server/node_modules/@storybook/node-logger": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/core-server/node_modules/@storybook/preview-api": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/channels": "8.1.10", - "@storybook/client-logger": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/csf": "^0.1.7", - "@storybook/global": "^5.0.0", - "@storybook/types": "8.1.10", - "@types/qs": "^6.9.5", - "dequal": "^2.0.2", - "lodash": "^4.17.21", - "memoizerific": "^1.11.3", - "qs": "^6.10.0", - "tiny-invariant": "^1.3.1", - "ts-dedent": "^2.0.0", - "util-deprecate": "^1.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/core-server/node_modules/@storybook/types": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/channels": "8.1.10", - "@types/express": "^4.7.0", - "file-system-cache": "2.3.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/core-server/node_modules/@types/node": { - "version": "18.19.39", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@storybook/core-server/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@storybook/core-server/node_modules/brace-expansion": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@storybook/core-server/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@storybook/core-server/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@storybook/core-server/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/core-server/node_modules/crypto-random-string": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-server/node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-server/node_modules/diff": { - "version": "5.2.0", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/@storybook/core-server/node_modules/find-cache-dir": { - "version": "3.3.2", - "dev": true, - "license": "MIT", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, - "node_modules/@storybook/core-server/node_modules/find-cache-dir/node_modules/find-up": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-server/node_modules/find-cache-dir/node_modules/pkg-dir": { - "version": "4.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-server/node_modules/fs-extra": { - "version": "11.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@storybook/core-server/node_modules/glob": { - "version": "10.4.2", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@storybook/core-server/node_modules/globby": { - "version": "14.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.2", - "ignore": "^5.2.4", - "path-type": "^5.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-server/node_modules/has-flag": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-server/node_modules/is-stream": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-server/node_modules/jackspeak": { - "version": "3.4.0", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/@storybook/core-server/node_modules/locate-path": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-server/node_modules/make-dir": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-server/node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@storybook/core-server/node_modules/minimatch": { - "version": "9.0.4", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@storybook/core-server/node_modules/minipass": { - "version": "7.1.2", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/@storybook/core-server/node_modules/p-limit": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-server/node_modules/p-locate": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-server/node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-server/node_modules/path-type": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-server/node_modules/recast": { - "version": "0.23.9", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/@storybook/core-server/node_modules/slash": { - "version": "5.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-server/node_modules/source-map": { - "version": "0.6.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@storybook/core-server/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/core-server/node_modules/temp-dir": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@storybook/core-server/node_modules/tempy": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "is-stream": "^3.0.0", - "temp-dir": "^3.0.0", - "type-fest": "^2.12.2", - "unique-string": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-server/node_modules/type-fest": { - "version": "2.19.0", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-server/node_modules/unique-string": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "crypto-random-string": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/core-server/node_modules/util": { - "version": "0.12.5", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, - "node_modules/@storybook/core-webpack": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/core-common": "8.1.6", - "@storybook/node-logger": "8.1.6", - "@storybook/types": "8.1.6", - "@types/node": "^18.0.0", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/core-webpack/node_modules/@types/node": { - "version": "18.19.34", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@storybook/csf": { - "version": "0.1.9", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^2.19.0" - } - }, - "node_modules/@storybook/csf-plugin": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/csf-tools": "8.1.10", - "unplugin": "^1.3.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/csf-plugin/node_modules/@babel/traverse": { - "version": "7.24.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@storybook/csf-plugin/node_modules/@storybook/channels": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/client-logger": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/global": "^5.0.0", - "telejson": "^7.2.0", - "tiny-invariant": "^1.3.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/csf-plugin/node_modules/@storybook/client-logger": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/csf-plugin/node_modules/@storybook/core-events": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/csf": "^0.1.7", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/csf-plugin/node_modules/@storybook/csf-tools": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", - "@storybook/csf": "^0.1.7", - "@storybook/types": "8.1.10", - "fs-extra": "^11.1.0", - "recast": "^0.23.5", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/csf-plugin/node_modules/@storybook/types": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/channels": "8.1.10", - "@types/express": "^4.7.0", - "file-system-cache": "2.3.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/csf-plugin/node_modules/fs-extra": { - "version": "11.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@storybook/csf-plugin/node_modules/recast": { - "version": "0.23.9", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/@storybook/csf-plugin/node_modules/source-map": { - "version": "0.6.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@storybook/csf-tools": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", - "@storybook/csf": "^0.1.7", - "@storybook/types": "8.1.6", - "fs-extra": "^11.1.0", - "recast": "^0.23.5", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/csf-tools/node_modules/@babel/traverse": { - "version": "7.24.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@storybook/csf-tools/node_modules/fs-extra": { - "version": "11.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@storybook/csf-tools/node_modules/recast": { - "version": "0.23.9", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/@storybook/csf-tools/node_modules/source-map": { - "version": "0.6.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@storybook/csf/node_modules/type-fest": { - "version": "2.19.0", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/docs-mdx": { - "version": "3.1.0-next.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/docs-tools": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/core-common": "8.1.6", - "@storybook/core-events": "8.1.6", - "@storybook/preview-api": "8.1.6", - "@storybook/types": "8.1.6", - "@types/doctrine": "^0.0.3", - "assert": "^2.1.0", - "doctrine": "^3.0.0", - "lodash": "^4.17.21" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/docs-tools/node_modules/assert": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "is-nan": "^1.3.2", - "object-is": "^1.1.5", - "object.assign": "^4.1.4", - "util": "^0.12.5" - } - }, - "node_modules/@storybook/docs-tools/node_modules/util": { - "version": "0.12.5", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, - "node_modules/@storybook/global": { - "version": "5.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/icons": { - "version": "1.2.9", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/@storybook/manager": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/manager-api": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/channels": "8.1.10", - "@storybook/client-logger": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/csf": "^0.1.7", - "@storybook/global": "^5.0.0", - "@storybook/icons": "^1.2.5", - "@storybook/router": "8.1.10", - "@storybook/theming": "8.1.10", - "@storybook/types": "8.1.10", - "dequal": "^2.0.2", - "lodash": "^4.17.21", - "memoizerific": "^1.11.3", - "store2": "^2.14.2", - "telejson": "^7.2.0", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/manager-api/node_modules/@storybook/channels": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/client-logger": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/global": "^5.0.0", - "telejson": "^7.2.0", - "tiny-invariant": "^1.3.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/manager-api/node_modules/@storybook/client-logger": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/manager-api/node_modules/@storybook/core-events": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/csf": "^0.1.7", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/manager-api/node_modules/@storybook/types": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/channels": "8.1.10", - "@types/express": "^4.7.0", - "file-system-cache": "2.3.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/node-logger": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/preset-react-webpack": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/core-webpack": "8.1.6", - "@storybook/docs-tools": "8.1.6", - "@storybook/node-logger": "8.1.6", - "@storybook/react": "8.1.6", - "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0", - "@types/node": "^18.0.0", - "@types/semver": "^7.3.4", - "find-up": "^5.0.0", - "fs-extra": "^11.1.0", - "magic-string": "^0.30.5", - "react-docgen": "^7.0.0", - "resolve": "^1.22.8", - "semver": "^7.3.7", - "tsconfig-paths": "^4.2.0", - "webpack": "5" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/preset-react-webpack/node_modules/@storybook/react": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/client-logger": "8.1.6", - "@storybook/docs-tools": "8.1.6", - "@storybook/global": "^5.0.0", - "@storybook/preview-api": "8.1.6", - "@storybook/react-dom-shim": "8.1.6", - "@storybook/types": "8.1.6", - "@types/escodegen": "^0.0.6", - "@types/estree": "^0.0.51", - "@types/node": "^18.0.0", - "acorn": "^7.4.1", - "acorn-jsx": "^5.3.1", - "acorn-walk": "^7.2.0", - "escodegen": "^2.1.0", - "html-tags": "^3.1.0", - "lodash": "^4.17.21", - "prop-types": "^15.7.2", - "react-element-to-jsx-string": "^15.0.0", - "semver": "^7.3.7", - "ts-dedent": "^2.0.0", - "type-fest": "~2.19", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "typescript": ">= 4.2.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/preset-react-webpack/node_modules/@storybook/react-dom-shim": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" - } - }, - "node_modules/@storybook/preset-react-webpack/node_modules/@types/node": { - "version": "18.19.30", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@storybook/preset-react-webpack/node_modules/fs-extra": { - "version": "11.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@storybook/preset-react-webpack/node_modules/type-fest": { - "version": "2.19.0", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/preview": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/preview-api": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/channels": "8.1.6", - "@storybook/client-logger": "8.1.6", - "@storybook/core-events": "8.1.6", - "@storybook/csf": "^0.1.7", - "@storybook/global": "^5.0.0", - "@storybook/types": "8.1.6", - "@types/qs": "^6.9.5", - "dequal": "^2.0.2", - "lodash": "^4.17.21", - "memoizerific": "^1.11.3", - "qs": "^6.10.0", - "tiny-invariant": "^1.3.1", - "ts-dedent": "^2.0.0", - "util-deprecate": "^1.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/react": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/client-logger": "8.1.10", - "@storybook/docs-tools": "8.1.10", - "@storybook/global": "^5.0.0", - "@storybook/preview-api": "8.1.10", - "@storybook/react-dom-shim": "8.1.10", - "@storybook/types": "8.1.10", - "@types/escodegen": "^0.0.6", - "@types/estree": "^0.0.51", - "@types/node": "^18.0.0", - "acorn": "^7.4.1", - "acorn-jsx": "^5.3.1", - "acorn-walk": "^7.2.0", - "escodegen": "^2.1.0", - "html-tags": "^3.1.0", - "lodash": "^4.17.21", - "prop-types": "^15.7.2", - "react-element-to-jsx-string": "^15.0.0", - "semver": "^7.3.7", - "ts-dedent": "^2.0.0", - "type-fest": "~2.19", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "typescript": ">= 4.2.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/react-docgen-typescript-plugin": { - "version": "1.0.6--canary.9.0c3f3b7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "endent": "^2.0.1", - "find-cache-dir": "^3.3.1", - "flat-cache": "^3.0.4", - "micromatch": "^4.0.2", - "react-docgen-typescript": "^2.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "typescript": ">= 4.x", - "webpack": ">= 4" - } - }, - "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/find-cache-dir": { - "version": "3.3.2", - "dev": true, - "license": "MIT", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, - "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/find-up": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/locate-path": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/make-dir": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/p-limit": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/p-locate": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/pkg-dir": { - "version": "4.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@storybook/react-dom-shim": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" - } - }, - "node_modules/@storybook/react-webpack5": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/builder-webpack5": "8.1.6", - "@storybook/preset-react-webpack": "8.1.6", - "@storybook/react": "8.1.6", - "@storybook/types": "8.1.6", - "@types/node": "^18.0.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "typescript": ">= 4.2.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/react": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/client-logger": "8.1.6", - "@storybook/docs-tools": "8.1.6", - "@storybook/global": "^5.0.0", - "@storybook/preview-api": "8.1.6", - "@storybook/react-dom-shim": "8.1.6", - "@storybook/types": "8.1.6", - "@types/escodegen": "^0.0.6", - "@types/estree": "^0.0.51", - "@types/node": "^18.0.0", - "acorn": "^7.4.1", - "acorn-jsx": "^5.3.1", - "acorn-walk": "^7.2.0", - "escodegen": "^2.1.0", - "html-tags": "^3.1.0", - "lodash": "^4.17.21", - "prop-types": "^15.7.2", - "react-element-to-jsx-string": "^15.0.0", - "semver": "^7.3.7", - "ts-dedent": "^2.0.0", - "type-fest": "~2.19", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "typescript": ">= 4.2.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/react-dom-shim": { - "version": "8.1.6", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@types/node": { - "version": "18.19.28", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@storybook/react-webpack5/node_modules/type-fest": { - "version": "2.19.0", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/react/node_modules/@babel/traverse": { - "version": "7.24.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@storybook/react/node_modules/@storybook/channels": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/client-logger": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/global": "^5.0.0", - "telejson": "^7.2.0", - "tiny-invariant": "^1.3.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/react/node_modules/@storybook/client-logger": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/react/node_modules/@storybook/core-common": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/core-events": "8.1.10", - "@storybook/csf-tools": "8.1.10", - "@storybook/node-logger": "8.1.10", - "@storybook/types": "8.1.10", - "@yarnpkg/fslib": "2.10.3", - "@yarnpkg/libzip": "2.3.0", - "chalk": "^4.1.0", - "cross-spawn": "^7.0.3", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0", - "esbuild-register": "^3.5.0", - "execa": "^5.0.0", - "file-system-cache": "2.3.0", - "find-cache-dir": "^3.0.0", - "find-up": "^5.0.0", - "fs-extra": "^11.1.0", - "glob": "^10.0.0", - "handlebars": "^4.7.7", - "lazy-universal-dotenv": "^4.0.0", - "node-fetch": "^2.0.0", - "picomatch": "^2.3.0", - "pkg-dir": "^5.0.0", - "prettier-fallback": "npm:prettier@^3", - "pretty-hrtime": "^1.0.3", - "resolve-from": "^5.0.0", - "semver": "^7.3.7", - "tempy": "^3.1.0", - "tiny-invariant": "^1.3.1", - "ts-dedent": "^2.0.0", - "util": "^0.12.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "prettier": "^2 || ^3" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - } - } - }, - "node_modules/@storybook/react/node_modules/@storybook/core-events": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/csf": "^0.1.7", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/react/node_modules/@storybook/csf-tools": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", - "@storybook/csf": "^0.1.7", - "@storybook/types": "8.1.10", - "fs-extra": "^11.1.0", - "recast": "^0.23.5", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/react/node_modules/@storybook/docs-tools": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/core-common": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/preview-api": "8.1.10", - "@storybook/types": "8.1.10", - "@types/doctrine": "^0.0.3", - "assert": "^2.1.0", - "doctrine": "^3.0.0", - "lodash": "^4.17.21" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/react/node_modules/@storybook/node-logger": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/react/node_modules/@storybook/preview-api": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/channels": "8.1.10", - "@storybook/client-logger": "8.1.10", - "@storybook/core-events": "8.1.10", - "@storybook/csf": "^0.1.7", - "@storybook/global": "^5.0.0", - "@storybook/types": "8.1.10", - "@types/qs": "^6.9.5", - "dequal": "^2.0.2", - "lodash": "^4.17.21", - "memoizerific": "^1.11.3", - "qs": "^6.10.0", - "tiny-invariant": "^1.3.1", - "ts-dedent": "^2.0.0", - "util-deprecate": "^1.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/react/node_modules/@storybook/types": { - "version": "8.1.10", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/channels": "8.1.10", - "@types/express": "^4.7.0", - "file-system-cache": "2.3.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - } - }, - "node_modules/@storybook/react/node_modules/@types/node": { - "version": "18.19.39", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@storybook/react/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@storybook/react/node_modules/assert": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "is-nan": "^1.3.2", - "object-is": "^1.1.5", - "object.assign": "^4.1.4", - "util": "^0.12.5" - } - }, - "node_modules/@storybook/react/node_modules/brace-expansion": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@storybook/react/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@storybook/react/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@storybook/react/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/react/node_modules/crypto-random-string": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/react/node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=10" + "node": ">=18.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@storybook/react/node_modules/find-cache-dir": { - "version": "3.3.2", + "node_modules/@storybook/preset-react-webpack/node_modules/@storybook/react": { + "version": "8.1.6", "dev": true, "license": "MIT", "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" + "@storybook/client-logger": "8.1.6", + "@storybook/docs-tools": "8.1.6", + "@storybook/global": "^5.0.0", + "@storybook/preview-api": "8.1.6", + "@storybook/react-dom-shim": "8.1.6", + "@storybook/types": "8.1.6", + "@types/escodegen": "^0.0.6", + "@types/estree": "^0.0.51", + "@types/node": "^18.0.0", + "acorn": "^7.4.1", + "acorn-jsx": "^5.3.1", + "acorn-walk": "^7.2.0", + "escodegen": "^2.1.0", + "html-tags": "^3.1.0", + "lodash": "^4.17.21", + "prop-types": "^15.7.2", + "react-element-to-jsx-string": "^15.0.0", + "semver": "^7.3.7", + "ts-dedent": "^2.0.0", + "type-fest": "~2.19", + "util-deprecate": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">=18.0.0" }, "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "typescript": ">= 4.2.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@storybook/react/node_modules/find-cache-dir/node_modules/find-up": { - "version": "4.1.0", + "node_modules/@storybook/preset-react-webpack/node_modules/@storybook/react-dom-shim": { + "version": "8.1.6", "dev": true, "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" } }, - "node_modules/@storybook/react/node_modules/find-cache-dir/node_modules/pkg-dir": { - "version": "4.2.0", + "node_modules/@storybook/preset-react-webpack/node_modules/@types/node": { + "version": "18.19.30", "dev": true, "license": "MIT", "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" + "undici-types": "~5.26.4" } }, - "node_modules/@storybook/react/node_modules/fs-extra": { + "node_modules/@storybook/preset-react-webpack/node_modules/fs-extra": { "version": "11.2.0", "dev": true, "license": "MIT", @@ -15911,120 +13585,168 @@ "node": ">=14.14" } }, - "node_modules/@storybook/react/node_modules/glob": { - "version": "10.4.2", + "node_modules/@storybook/preset-react-webpack/node_modules/type-fest": { + "version": "2.19.0", "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">=12.20" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/react/node_modules/has-flag": { - "version": "4.0.0", + "node_modules/@storybook/preview": { + "version": "8.1.6", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/react/node_modules/is-stream": { - "version": "3.0.0", + "node_modules/@storybook/preview-api": { + "version": "8.1.6", "dev": true, "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "dependencies": { + "@storybook/channels": "8.1.6", + "@storybook/client-logger": "8.1.6", + "@storybook/core-events": "8.1.6", + "@storybook/csf": "^0.1.7", + "@storybook/global": "^5.0.0", + "@storybook/types": "8.1.6", + "@types/qs": "^6.9.5", + "dequal": "^2.0.2", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3", + "qs": "^6.10.0", + "tiny-invariant": "^1.3.1", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/react/node_modules/jackspeak": { - "version": "3.4.0", + "node_modules/@storybook/react": { + "version": "8.1.10", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "@storybook/client-logger": "8.1.10", + "@storybook/docs-tools": "8.1.10", + "@storybook/global": "^5.0.0", + "@storybook/preview-api": "8.1.10", + "@storybook/react-dom-shim": "8.1.10", + "@storybook/types": "8.1.10", + "@types/escodegen": "^0.0.6", + "@types/estree": "^0.0.51", + "@types/node": "^18.0.0", + "acorn": "^7.4.1", + "acorn-jsx": "^5.3.1", + "acorn-walk": "^7.2.0", + "escodegen": "^2.1.0", + "html-tags": "^3.1.0", + "lodash": "^4.17.21", + "prop-types": "^15.7.2", + "react-element-to-jsx-string": "^15.0.0", + "semver": "^7.3.7", + "ts-dedent": "^2.0.0", + "type-fest": "~2.19", + "util-deprecate": "^1.0.2" }, "engines": { - "node": ">=14" + "node": ">=18.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/storybook" }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "typescript": ">= 4.2.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@storybook/react/node_modules/locate-path": { - "version": "5.0.0", + "node_modules/@storybook/react-docgen-typescript-plugin": { + "version": "1.0.6--canary.9.0c3f3b7.0", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "debug": "^4.1.1", + "endent": "^2.0.1", + "find-cache-dir": "^3.3.1", + "flat-cache": "^3.0.4", + "micromatch": "^4.0.2", + "react-docgen-typescript": "^2.2.2", + "tslib": "^2.0.0" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "typescript": ">= 4.x", + "webpack": ">= 4" } }, - "node_modules/@storybook/react/node_modules/make-dir": { - "version": "3.1.0", + "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/find-cache-dir": { + "version": "3.3.2", "dev": true, "license": "MIT", "dependencies": { - "semver": "^6.0.0" + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" }, "engines": { "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, - "node_modules/@storybook/react/node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", + "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/find-up": { + "version": "4.1.0", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@storybook/react/node_modules/minimatch": { - "version": "9.0.4", + "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/locate-path": { + "version": "5.0.0", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "p-locate": "^4.1.0" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=8" } }, - "node_modules/@storybook/react/node_modules/minipass": { - "version": "7.1.2", + "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/make-dir": { + "version": "3.1.0", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/react/node_modules/p-limit": { + "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/p-limit": { "version": "2.3.0", "dev": true, "license": "MIT", @@ -16038,7 +13760,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/react/node_modules/p-locate": { + "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/p-locate": { "version": "4.1.0", "dev": true, "license": "MIT", @@ -16049,7 +13771,7 @@ "node": ">=8" } }, - "node_modules/@storybook/react/node_modules/path-exists": { + "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/path-exists": { "version": "4.0.0", "dev": true, "license": "MIT", @@ -16057,148 +13779,145 @@ "node": ">=8" } }, - "node_modules/@storybook/react/node_modules/recast": { - "version": "0.23.9", + "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/pkg-dir": { + "version": "4.2.0", "dev": true, "license": "MIT", "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" + "find-up": "^4.0.0" }, "engines": { - "node": ">= 4" + "node": ">=8" } }, - "node_modules/@storybook/react/node_modules/source-map": { - "version": "0.6.1", + "node_modules/@storybook/react-docgen-typescript-plugin/node_modules/semver": { + "version": "6.3.1", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/@storybook/react/node_modules/supports-color": { - "version": "7.2.0", + "node_modules/@storybook/react-dom-shim": { + "version": "8.1.10", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/react/node_modules/temp-dir": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" } }, - "node_modules/@storybook/react/node_modules/tempy": { - "version": "3.1.0", + "node_modules/@storybook/react-webpack5": { + "version": "8.1.6", "dev": true, "license": "MIT", "dependencies": { - "is-stream": "^3.0.0", - "temp-dir": "^3.0.0", - "type-fest": "^2.12.2", - "unique-string": "^3.0.0" + "@storybook/builder-webpack5": "8.1.6", + "@storybook/preset-react-webpack": "8.1.6", + "@storybook/react": "8.1.6", + "@storybook/types": "8.1.6", + "@types/node": "^18.0.0" }, "engines": { - "node": ">=14.16" + "node": ">=18.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/react/node_modules/type-fest": { - "version": "2.19.0", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" + "type": "opencollective", + "url": "https://opencollective.com/storybook" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "typescript": ">= 4.2.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@storybook/react/node_modules/unique-string": { - "version": "3.0.0", + "node_modules/@storybook/react-webpack5/node_modules/@storybook/react": { + "version": "8.1.6", "dev": true, "license": "MIT", "dependencies": { - "crypto-random-string": "^4.0.0" + "@storybook/client-logger": "8.1.6", + "@storybook/docs-tools": "8.1.6", + "@storybook/global": "^5.0.0", + "@storybook/preview-api": "8.1.6", + "@storybook/react-dom-shim": "8.1.6", + "@storybook/types": "8.1.6", + "@types/escodegen": "^0.0.6", + "@types/estree": "^0.0.51", + "@types/node": "^18.0.0", + "acorn": "^7.4.1", + "acorn-jsx": "^5.3.1", + "acorn-walk": "^7.2.0", + "escodegen": "^2.1.0", + "html-tags": "^3.1.0", + "lodash": "^4.17.21", + "prop-types": "^15.7.2", + "react-element-to-jsx-string": "^15.0.0", + "semver": "^7.3.7", + "ts-dedent": "^2.0.0", + "type-fest": "~2.19", + "util-deprecate": "^1.0.2" }, "engines": { - "node": ">=12" + "node": ">=18.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/react/node_modules/util": { - "version": "0.12.5", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "typescript": ">= 4.2.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@storybook/router": { - "version": "8.1.10", + "node_modules/@storybook/react-webpack5/node_modules/@storybook/react-dom-shim": { + "version": "8.1.6", "dev": true, "license": "MIT", - "dependencies": { - "@storybook/client-logger": "8.1.10", - "memoizerific": "^1.11.3", - "qs": "^6.10.0" - }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" } }, - "node_modules/@storybook/router/node_modules/@storybook/client-logger": { - "version": "8.1.10", + "node_modules/@storybook/react-webpack5/node_modules/@types/node": { + "version": "18.19.28", "dev": true, "license": "MIT", "dependencies": { - "@storybook/global": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" + "undici-types": "~5.26.4" } }, - "node_modules/@storybook/telemetry": { - "version": "8.1.10", + "node_modules/@storybook/react-webpack5/node_modules/type-fest": { + "version": "2.19.0", "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/client-logger": "8.1.10", - "@storybook/core-common": "8.1.10", - "@storybook/csf-tools": "8.1.10", - "chalk": "^4.1.0", - "detect-package-manager": "^2.0.1", - "fetch-retry": "^5.0.2", - "fs-extra": "^11.1.0", - "read-pkg-up": "^7.0.1" + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/telemetry/node_modules/@babel/traverse": { + "node_modules/@storybook/react/node_modules/@babel/traverse": { "version": "7.24.7", "dev": true, "license": "MIT", @@ -16218,7 +13937,7 @@ "node": ">=6.9.0" } }, - "node_modules/@storybook/telemetry/node_modules/@storybook/channels": { + "node_modules/@storybook/react/node_modules/@storybook/channels": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -16234,7 +13953,7 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/telemetry/node_modules/@storybook/client-logger": { + "node_modules/@storybook/react/node_modules/@storybook/client-logger": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -16246,7 +13965,7 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/telemetry/node_modules/@storybook/core-common": { + "node_modules/@storybook/react/node_modules/@storybook/core-common": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -16294,7 +14013,7 @@ } } }, - "node_modules/@storybook/telemetry/node_modules/@storybook/core-events": { + "node_modules/@storybook/react/node_modules/@storybook/core-events": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -16307,7 +14026,7 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/telemetry/node_modules/@storybook/csf-tools": { + "node_modules/@storybook/react/node_modules/@storybook/csf-tools": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -16327,16 +14046,60 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/telemetry/node_modules/@storybook/node-logger": { + "node_modules/@storybook/react/node_modules/@storybook/docs-tools": { + "version": "8.1.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/core-common": "8.1.10", + "@storybook/core-events": "8.1.10", + "@storybook/preview-api": "8.1.10", + "@storybook/types": "8.1.10", + "@types/doctrine": "^0.0.3", + "assert": "^2.1.0", + "doctrine": "^3.0.0", + "lodash": "^4.17.21" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/react/node_modules/@storybook/node-logger": { + "version": "8.1.10", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/react/node_modules/@storybook/preview-api": { "version": "8.1.10", "dev": true, "license": "MIT", + "dependencies": { + "@storybook/channels": "8.1.10", + "@storybook/client-logger": "8.1.10", + "@storybook/core-events": "8.1.10", + "@storybook/csf": "^0.1.7", + "@storybook/global": "^5.0.0", + "@storybook/types": "8.1.10", + "@types/qs": "^6.9.5", + "dequal": "^2.0.2", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3", + "qs": "^6.10.0", + "tiny-invariant": "^1.3.1", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/telemetry/node_modules/@storybook/types": { + "node_modules/@storybook/react/node_modules/@storybook/types": { "version": "8.1.10", "dev": true, "license": "MIT", @@ -16350,7 +14113,15 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/telemetry/node_modules/ansi-styles": { + "node_modules/@storybook/react/node_modules/@types/node": { + "version": "18.19.39", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@storybook/react/node_modules/ansi-styles": { "version": "4.3.0", "dev": true, "license": "MIT", @@ -16364,7 +14135,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@storybook/telemetry/node_modules/brace-expansion": { + "node_modules/@storybook/react/node_modules/assert": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, + "node_modules/@storybook/react/node_modules/brace-expansion": { "version": "2.0.1", "dev": true, "license": "MIT", @@ -16372,7 +14155,7 @@ "balanced-match": "^1.0.0" } }, - "node_modules/@storybook/telemetry/node_modules/chalk": { + "node_modules/@storybook/react/node_modules/chalk": { "version": "4.1.2", "dev": true, "license": "MIT", @@ -16387,7 +14170,7 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@storybook/telemetry/node_modules/color-convert": { + "node_modules/@storybook/react/node_modules/color-convert": { "version": "2.0.1", "dev": true, "license": "MIT", @@ -16398,12 +14181,12 @@ "node": ">=7.0.0" } }, - "node_modules/@storybook/telemetry/node_modules/color-name": { + "node_modules/@storybook/react/node_modules/color-name": { "version": "1.1.4", "dev": true, "license": "MIT" }, - "node_modules/@storybook/telemetry/node_modules/crypto-random-string": { + "node_modules/@storybook/react/node_modules/crypto-random-string": { "version": "4.0.0", "dev": true, "license": "MIT", @@ -16417,7 +14200,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/telemetry/node_modules/crypto-random-string/node_modules/type-fest": { + "node_modules/@storybook/react/node_modules/crypto-random-string/node_modules/type-fest": { "version": "1.4.0", "dev": true, "license": "(MIT OR CC0-1.0)", @@ -16428,7 +14211,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/telemetry/node_modules/find-cache-dir": { + "node_modules/@storybook/react/node_modules/find-cache-dir": { "version": "3.3.2", "dev": true, "license": "MIT", @@ -16444,7 +14227,7 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, - "node_modules/@storybook/telemetry/node_modules/find-cache-dir/node_modules/find-up": { + "node_modules/@storybook/react/node_modules/find-cache-dir/node_modules/find-up": { "version": "4.1.0", "dev": true, "license": "MIT", @@ -16456,7 +14239,7 @@ "node": ">=8" } }, - "node_modules/@storybook/telemetry/node_modules/find-cache-dir/node_modules/pkg-dir": { + "node_modules/@storybook/react/node_modules/find-cache-dir/node_modules/pkg-dir": { "version": "4.2.0", "dev": true, "license": "MIT", @@ -16467,7 +14250,7 @@ "node": ">=8" } }, - "node_modules/@storybook/telemetry/node_modules/fs-extra": { + "node_modules/@storybook/react/node_modules/fs-extra": { "version": "11.2.0", "dev": true, "license": "MIT", @@ -16480,7 +14263,7 @@ "node": ">=14.14" } }, - "node_modules/@storybook/telemetry/node_modules/glob": { + "node_modules/@storybook/react/node_modules/glob": { "version": "10.4.2", "dev": true, "license": "ISC", @@ -16502,7 +14285,7 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@storybook/telemetry/node_modules/has-flag": { + "node_modules/@storybook/react/node_modules/has-flag": { "version": "4.0.0", "dev": true, "license": "MIT", @@ -16510,7 +14293,7 @@ "node": ">=8" } }, - "node_modules/@storybook/telemetry/node_modules/is-stream": { + "node_modules/@storybook/react/node_modules/is-stream": { "version": "3.0.0", "dev": true, "license": "MIT", @@ -16521,7 +14304,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/telemetry/node_modules/jackspeak": { + "node_modules/@storybook/react/node_modules/jackspeak": { "version": "3.4.0", "dev": true, "license": "BlueOak-1.0.0", @@ -16538,7 +14321,7 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/@storybook/telemetry/node_modules/locate-path": { + "node_modules/@storybook/react/node_modules/locate-path": { "version": "5.0.0", "dev": true, "license": "MIT", @@ -16549,7 +14332,7 @@ "node": ">=8" } }, - "node_modules/@storybook/telemetry/node_modules/make-dir": { + "node_modules/@storybook/react/node_modules/make-dir": { "version": "3.1.0", "dev": true, "license": "MIT", @@ -16563,7 +14346,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/telemetry/node_modules/make-dir/node_modules/semver": { + "node_modules/@storybook/react/node_modules/make-dir/node_modules/semver": { "version": "6.3.1", "dev": true, "license": "ISC", @@ -16571,7 +14354,7 @@ "semver": "bin/semver.js" } }, - "node_modules/@storybook/telemetry/node_modules/minimatch": { + "node_modules/@storybook/react/node_modules/minimatch": { "version": "9.0.4", "dev": true, "license": "ISC", @@ -16585,7 +14368,7 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@storybook/telemetry/node_modules/minipass": { + "node_modules/@storybook/react/node_modules/minipass": { "version": "7.1.2", "dev": true, "license": "ISC", @@ -16593,7 +14376,7 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/@storybook/telemetry/node_modules/p-limit": { + "node_modules/@storybook/react/node_modules/p-limit": { "version": "2.3.0", "dev": true, "license": "MIT", @@ -16607,7 +14390,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/telemetry/node_modules/p-locate": { + "node_modules/@storybook/react/node_modules/p-locate": { "version": "4.1.0", "dev": true, "license": "MIT", @@ -16618,7 +14401,7 @@ "node": ">=8" } }, - "node_modules/@storybook/telemetry/node_modules/path-exists": { + "node_modules/@storybook/react/node_modules/path-exists": { "version": "4.0.0", "dev": true, "license": "MIT", @@ -16626,7 +14409,7 @@ "node": ">=8" } }, - "node_modules/@storybook/telemetry/node_modules/recast": { + "node_modules/@storybook/react/node_modules/recast": { "version": "0.23.9", "dev": true, "license": "MIT", @@ -16641,7 +14424,7 @@ "node": ">= 4" } }, - "node_modules/@storybook/telemetry/node_modules/source-map": { + "node_modules/@storybook/react/node_modules/source-map": { "version": "0.6.1", "dev": true, "license": "BSD-3-Clause", @@ -16649,7 +14432,7 @@ "node": ">=0.10.0" } }, - "node_modules/@storybook/telemetry/node_modules/supports-color": { + "node_modules/@storybook/react/node_modules/supports-color": { "version": "7.2.0", "dev": true, "license": "MIT", @@ -16660,7 +14443,7 @@ "node": ">=8" } }, - "node_modules/@storybook/telemetry/node_modules/temp-dir": { + "node_modules/@storybook/react/node_modules/temp-dir": { "version": "3.0.0", "dev": true, "license": "MIT", @@ -16668,7 +14451,7 @@ "node": ">=14.16" } }, - "node_modules/@storybook/telemetry/node_modules/tempy": { + "node_modules/@storybook/react/node_modules/tempy": { "version": "3.1.0", "dev": true, "license": "MIT", @@ -16685,7 +14468,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/telemetry/node_modules/type-fest": { + "node_modules/@storybook/react/node_modules/type-fest": { "version": "2.19.0", "dev": true, "license": "(MIT OR CC0-1.0)", @@ -16696,7 +14479,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/telemetry/node_modules/unique-string": { + "node_modules/@storybook/react/node_modules/unique-string": { "version": "3.0.0", "dev": true, "license": "MIT", @@ -16710,7 +14493,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@storybook/telemetry/node_modules/util": { + "node_modules/@storybook/react/node_modules/util": { "version": "0.12.5", "dev": true, "license": "MIT", @@ -16722,6 +14505,32 @@ "which-typed-array": "^1.1.2" } }, + "node_modules/@storybook/router": { + "version": "8.1.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/client-logger": "8.1.10", + "memoizerific": "^1.11.3", + "qs": "^6.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/router/node_modules/@storybook/client-logger": { + "version": "8.1.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, "node_modules/@storybook/theming": { "version": "8.1.10", "dev": true, @@ -17467,6 +15276,8 @@ }, "node_modules/@types/cross-spawn": { "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", "dev": true, "license": "MIT", "dependencies": { @@ -17481,26 +15292,11 @@ "@types/ms": "*" } }, - "node_modules/@types/detect-port": { - "version": "1.3.5", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/diff": { - "version": "5.2.1", - "dev": true, - "license": "MIT" - }, "node_modules/@types/doctrine": { "version": "0.0.3", "dev": true, "license": "MIT" }, - "node_modules/@types/ejs": { - "version": "3.1.5", - "dev": true, - "license": "MIT" - }, "node_modules/@types/emscripten": { "version": "1.39.10", "dev": true, @@ -17782,11 +15578,6 @@ "@types/node": "*" } }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "dev": true, - "license": "MIT" - }, "node_modules/@types/parse-json": { "version": "4.0.0", "dev": true, @@ -17802,11 +15593,6 @@ "xmlbuilder": ">=11.0.1" } }, - "node_modules/@types/pretty-hrtime": { - "version": "1.0.3", - "dev": true, - "license": "MIT" - }, "node_modules/@types/prop-types": { "version": "15.7.5", "license": "MIT" @@ -18871,20 +16657,6 @@ "version": "4.2.2", "license": "Apache-2.0" }, - "node_modules/@yarnpkg/esbuild-plugin-pnp": { - "version": "3.0.0-rc.15", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "esbuild": ">=0.10.0" - } - }, "node_modules/@yarnpkg/fslib": { "version": "2.10.3", "dev": true, @@ -19028,14 +16800,6 @@ "node": ">=0.4.0" } }, - "node_modules/address": { - "version": "1.2.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/adm-zip": { "version": "0.5.10", "license": "MIT", @@ -21063,19 +18827,21 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.0", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "license": "MIT", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.10.3", - "raw-body": "2.5.1", + "qs": "6.13.0", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -21086,6 +18852,8 @@ }, "node_modules/body-parser/node_modules/bytes": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -21093,6 +18861,8 @@ }, "node_modules/body-parser/node_modules/debug": { "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -21100,6 +18870,8 @@ }, "node_modules/body-parser/node_modules/iconv-lite": { "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -21110,6 +18882,8 @@ }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/bonjour-service": { @@ -21142,17 +18916,6 @@ "stream-buffers": "2.2.x" } }, - "node_modules/bplist-parser": { - "version": "0.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "big-integer": "^1.6.44" - }, - "engines": { - "node": ">= 5.10.0" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "license": "MIT", @@ -21992,6 +19755,8 @@ }, "node_modules/citty": { "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "dev": true, "license": "MIT", "dependencies": { @@ -22157,20 +19922,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-table3": { - "version": "0.6.5", - "dev": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" - } - }, "node_modules/cli-truncate": { "version": "2.1.0", "dev": true, @@ -22385,11 +20136,13 @@ "license": "MIT" }, "node_modules/commander": { - "version": "6.2.1", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=18" } }, "node_modules/comment-parser": { @@ -22615,6 +20368,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/confbox": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", + "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", + "dev": true, + "license": "MIT" + }, "node_modules/config-file-ts": { "version": "0.2.8-rc1", "dev": true, @@ -22759,6 +20519,8 @@ }, "node_modules/consola": { "version": "3.2.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", + "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", "dev": true, "license": "MIT", "engines": { @@ -22807,6 +20569,8 @@ }, "node_modules/content-type": { "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -22826,7 +20590,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.5.0", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -23091,6 +20857,143 @@ "devOptional": true, "license": "MIT" }, + "node_modules/create-storybook": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/create-storybook/-/create-storybook-8.3.0.tgz", + "integrity": "sha512-MAcMWX7V4VE1W47O6tiwL4xBJprsa7b0cqLECNSKaW8nvr7LSFgveobIqWG7i1DqQg/cGWA09o2YRDc2LOFsmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/semver": "^7.3.4", + "chalk": "^4.1.0", + "commander": "^12.1.0", + "execa": "^5.0.0", + "fd-package-json": "^1.2.0", + "find-up": "^5.0.0", + "fs-extra": "^11.1.0", + "ora": "^5.4.1", + "prettier": "^3.1.1", + "prompts": "^2.4.0", + "semver": "^7.3.7", + "storybook": "8.3.0", + "tiny-invariant": "^1.3.1", + "ts-dedent": "^2.0.0" + }, + "bin": { + "create-storybook": "bin/index.cjs" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/create-storybook/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/create-storybook/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/create-storybook/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/create-storybook/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-storybook/node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/create-storybook/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/create-storybook/node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/create-storybook/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cross-fetch": { "version": "3.1.5", "license": "MIT", @@ -23610,21 +21513,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/default-browser-id": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "bplist-parser": "^0.2.0", - "untildify": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/default-browser/node_modules/default-browser-id": { "version": "5.0.0", "dev": true, @@ -23716,6 +21604,8 @@ }, "node_modules/defu": { "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "dev": true, "license": "MIT" }, @@ -23864,14 +21754,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-indent": { - "version": "6.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-libc": { "version": "2.0.1", "devOptional": true, @@ -23897,33 +21779,6 @@ "dev": true, "license": "MIT" }, - "node_modules/detect-package-manager": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/detect-port": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "address": "^1.0.1", - "debug": "4" - }, - "bin": { - "detect": "bin/detect-port.js", - "detect-port": "bin/detect-port.js" - }, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/diagnostic-channel": { "version": "1.1.1", "license": "MIT", @@ -24229,17 +22084,6 @@ "dev": true, "license": "MIT" }, - "node_modules/duplexify": { - "version": "3.7.1", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.0.0", - "inherits": "^2.0.1", - "readable-stream": "^2.0.0", - "stream-shift": "^1.0.0" - } - }, "node_modules/earcut": { "version": "2.2.4", "license": "ISC" @@ -24898,11 +22742,6 @@ "@esbuild/win32-x64": "0.20.2" } }, - "node_modules/esbuild-plugin-alias": { - "version": "0.2.1", - "dev": true, - "license": "MIT" - }, "node_modules/esbuild-register": { "version": "3.5.0", "dev": true, @@ -26271,9 +24110,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.83", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.83.tgz", - "integrity": "sha512-7CHVxV5yEJ43GGKF0UXiLKaSdfKaSHE4YC2+30gKxuWbs5XrOLOK3TcCzk54uBfbmPjmx6VrADbR9uzS4H0A0g==", + "version": "2.0.84", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.84.tgz", + "integrity": "sha512-VistjMexRz/1u1IqjIZwGRE7aS6QOat7420Dualn+NaqMHGkfeeB4uUR3RQhCtlDbcwFBKTryIGgSrrC0N1YpA==", "dependencies": { "awesome-phonenumber": "^5.4.0", "classnames": "2.5.0", @@ -26613,35 +24452,37 @@ "license": "Apache-2.0" }, "node_modules/express": { - "version": "4.18.1", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.20.0.tgz", + "integrity": "sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.0", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.2.0", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.10.3", + "qs": "6.11.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.0", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -26659,10 +24500,46 @@ "ms": "2.0.0" } }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/express/node_modules/ms": { "version": "2.0.0", "license": "MIT" }, + "node_modules/express/node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/express/node_modules/safe-buffer": { "version": "5.2.1", "funding": [ @@ -26681,6 +24558,45 @@ ], "license": "MIT" }, + "node_modules/express/node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express/node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/extend-shallow": { "version": "3.0.2", "license": "MIT", @@ -26916,6 +24832,16 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/fd-package-json": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-1.2.0.tgz", + "integrity": "sha512-45LSPmWf+gC5tdCQMNH4s9Sr00bIkiD9aN7dc5hqkrEw1geRYyDQS1v1oMHAW3ysfxfndqGsrDREHHjNNbKUfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^3.0.1" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "dev": true, @@ -26924,11 +24850,6 @@ "pend": "~1.2.0" } }, - "node_modules/fetch-retry": { - "version": "5.0.6", - "dev": true, - "license": "MIT" - }, "node_modules/file-entry-cache": { "version": "6.0.1", "dev": true, @@ -27459,7 +25380,8 @@ "node_modules/fs-constants": { "version": "1.0.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fs-extra": { "version": "9.1.0", @@ -27720,14 +25642,6 @@ "node": ">=6" } }, - "node_modules/get-npm-tarball-url": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "license": "MIT", @@ -27783,6 +25697,8 @@ }, "node_modules/giget": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.3.tgz", + "integrity": "sha512-8EHPljDvs7qKykr6uw8b+lqLiUc/vUg+KVTI0uND4s63TdsZM2Xus3mflvF0DDG9SiM4RlCkFGL+7aAjRmV7KA==", "dev": true, "license": "MIT", "dependencies": { @@ -27968,35 +25884,6 @@ "version": "1.1.0", "license": "ISC" }, - "node_modules/gunzip-maybe": { - "version": "1.4.2", - "dev": true, - "license": "MIT", - "dependencies": { - "browserify-zlib": "^0.1.4", - "is-deflate": "^1.0.0", - "is-gzip": "^1.0.0", - "peek-stream": "^1.1.0", - "pumpify": "^1.3.3", - "through2": "^2.0.3" - }, - "bin": { - "gunzip-maybe": "bin.js" - } - }, - "node_modules/gunzip-maybe/node_modules/browserify-zlib": { - "version": "0.1.4", - "dev": true, - "license": "MIT", - "dependencies": { - "pako": "~0.2.0" - } - }, - "node_modules/gunzip-maybe/node_modules/pako": { - "version": "0.2.9", - "dev": true, - "license": "MIT" - }, "node_modules/gzip-size": { "version": "6.0.0", "dev": true, @@ -28727,6 +26614,21 @@ "ms": "^2.0.0" } }, + "node_modules/husky": { + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.5.tgz", + "integrity": "sha512-rowAVRUBfI0b4+niA4SJMhfQwc107VLkBUgEYYAOQAbqDCnra1nYh83hF/MDmhYs9t9n1E3DuKOrs2LYNC+0Ag==", + "dev": true, + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/hyperdyperid": { "version": "1.2.0", "dev": true, @@ -29357,11 +27259,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-deflate": { - "version": "1.0.0", - "dev": true, - "license": "MIT" - }, "node_modules/is-descriptor": { "version": "1.0.2", "license": "MIT", @@ -29471,14 +27368,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-gzip": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "dev": true, @@ -33302,6 +31191,8 @@ }, "node_modules/media-typer": { "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -33399,8 +31290,13 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "license": "MIT" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-refs": { "version": "1.2.1", @@ -34149,10 +32045,31 @@ "node": ">=10" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", + "node_modules/mlly": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz", + "integrity": "sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "acorn": "^8.11.3", + "pathe": "^1.1.2", + "pkg-types": "^1.1.1", + "ufo": "^1.5.3" + } + }, + "node_modules/mlly/node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } }, "node_modules/module-details-from-path": { "version": "1.0.3", @@ -34437,6 +32354,8 @@ }, "node_modules/node-fetch-native": { "version": "1.6.4", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.4.tgz", + "integrity": "sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==", "dev": true, "license": "MIT" }, @@ -34743,7 +32662,9 @@ "license": "MIT" }, "node_modules/nypm": { - "version": "0.3.8", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.3.11.tgz", + "integrity": "sha512-E5GqaAYSnbb6n1qZyik2wjPDZON43FqOJO59+3OkWrnmQtjggrMOVnsyzfjxp/tS6nlYJBA4zRA5jSM2YaadMg==", "dev": true, "license": "MIT", "dependencies": { @@ -34751,7 +32672,8 @@ "consola": "^3.2.3", "execa": "^8.0.1", "pathe": "^1.1.2", - "ufo": "^1.4.0" + "pkg-types": "^1.2.0", + "ufo": "^1.5.4" }, "bin": { "nypm": "dist/cli.mjs" @@ -34762,6 +32684,8 @@ }, "node_modules/nypm/node_modules/execa": { "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, "license": "MIT", "dependencies": { @@ -34784,6 +32708,8 @@ }, "node_modules/nypm/node_modules/get-stream": { "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, "license": "MIT", "engines": { @@ -34795,6 +32721,8 @@ }, "node_modules/nypm/node_modules/human-signals": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -34803,6 +32731,8 @@ }, "node_modules/nypm/node_modules/is-stream": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, "license": "MIT", "engines": { @@ -34814,6 +32744,8 @@ }, "node_modules/nypm/node_modules/mimic-fn": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true, "license": "MIT", "engines": { @@ -34825,6 +32757,8 @@ }, "node_modules/nypm/node_modules/npm-run-path": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -34839,6 +32773,8 @@ }, "node_modules/nypm/node_modules/onetime": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -34853,6 +32789,8 @@ }, "node_modules/nypm/node_modules/path-key": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, "license": "MIT", "engines": { @@ -34864,6 +32802,8 @@ }, "node_modules/nypm/node_modules/signal-exit": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, "license": "ISC", "engines": { @@ -34875,6 +32815,8 @@ }, "node_modules/nypm/node_modules/strip-final-newline": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", "dev": true, "license": "MIT", "engines": { @@ -35124,6 +33066,8 @@ }, "node_modules/ohash": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.3.tgz", + "integrity": "sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==", "dev": true, "license": "MIT" }, @@ -35772,7 +33716,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", "license": "MIT" }, "node_modules/path-type": { @@ -35792,6 +33738,8 @@ }, "node_modules/pathe": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "dev": true, "license": "MIT" }, @@ -35840,16 +33788,6 @@ "npm": ">=6" } }, - "node_modules/peek-stream": { - "version": "1.1.3", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "duplexify": "^3.5.0", - "through2": "^2.0.3" - } - }, "node_modules/peggy": { "version": "4.0.3", "dev": true, @@ -35866,14 +33804,6 @@ "node": ">=18" } }, - "node_modules/peggy/node_modules/commander": { - "version": "12.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/pend": { "version": "1.2.0", "dev": true, @@ -35935,6 +33865,18 @@ "node": ">=10" } }, + "node_modules/pkg-types": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.0.tgz", + "integrity": "sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.7", + "mlly": "^1.7.1", + "pathe": "^1.1.2" + } + }, "node_modules/pkg-up": { "version": "3.1.0", "dev": true, @@ -36363,25 +34305,6 @@ "once": "^1.3.1" } }, - "node_modules/pumpify": { - "version": "1.5.1", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexify": "^3.6.0", - "inherits": "^2.0.3", - "pump": "^2.0.0" - } - }, - "node_modules/pumpify/node_modules/pump": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "license": "MIT", @@ -36578,10 +34501,12 @@ } }, "node_modules/qs": { - "version": "6.10.3", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -36712,7 +34637,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "license": "MIT", "dependencies": { "bytes": "3.1.2", @@ -36726,6 +34653,8 @@ }, "node_modules/raw-body/node_modules/bytes": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -36733,6 +34662,8 @@ }, "node_modules/raw-body/node_modules/iconv-lite": { "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -37314,8 +35245,8 @@ }, "node_modules/react-native-image-size": { "version": "1.1.3", - "resolved": "git+ssh://git@github.com/Expensify/react-native-image-size.git#93399c6410de32966eb57085936ef6951398c2c3", - "integrity": "sha512-hR38DhM3ewEv5VPhyCAbrhgWWlA1Hyys69BdUFkUes2wgiZc2ARVaXoLKuvzYT3g9fNYLwijylaSEs3juDkPKg==" + "resolved": "git+ssh://git@github.com/Expensify/react-native-image-size.git#cb392140db4953a283590d7cf93b4d0461baa2a9", + "integrity": "sha512-kF/8fGsKoOnjPZceipRUaM9Xg9a/aKXU2Vm5eHYEKHrRt8FP39oCbaELPTb/vUKRTu1HmEGffDFzRT02BcdzYQ==" }, "node_modules/react-native-key-command": { "version": "1.0.8", @@ -40093,108 +38024,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/read-pkg-up": { - "version": "7.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up/node_modules/find-up": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/locate-path": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up/node_modules/p-locate": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/read-pkg": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/read-pkg/node_modules/type-fest": { - "version": "0.6.0", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/type-fest": { - "version": "0.8.1", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, "node_modules/readable-stream": { "version": "2.3.8", "license": "MIT", @@ -41160,7 +38989,9 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.0.tgz", + "integrity": "sha512-pDLK8zwl2eKaYrs8mrPZBJua4hMplRWJ1tIFksVC3FtBEBnl8dxgeHtsaMS8DhS9i4fLObaon6ABoc4/hQGdPA==", "license": "MIT", "dependencies": { "encodeurl": "~1.0.2", @@ -41351,12 +39182,18 @@ "peer": true }, "node_modules/side-channel": { - "version": "1.0.4", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -42190,15 +40027,18 @@ "license": "MIT" }, "node_modules/storybook": { - "version": "8.1.10", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.3.0.tgz", + "integrity": "sha512-XKU+nem9OKX/juvJPwka1Q7DTpSbOe0IMp8ZyLQWorhFKpquJdUjryl7Z9GiFZyyTykCqH4ItQ7h8PaOmqVMOw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/cli": "8.1.10" + "@storybook/core": "8.3.0" }, "bin": { - "sb": "index.js", - "storybook": "index.js" + "getstorybook": "bin/index.cjs", + "sb": "bin/index.cjs", + "storybook": "bin/index.cjs" }, "funding": { "type": "opencollective", @@ -42231,11 +40071,6 @@ "xtend": "^4.0.0" } }, - "node_modules/stream-shift": { - "version": "1.0.3", - "dev": true, - "license": "MIT" - }, "node_modules/strict-uri-encode": { "version": "2.0.0", "license": "MIT", @@ -42727,26 +40562,11 @@ "node": ">=10" } }, - "node_modules/tar-fs": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "dev": true, - "license": "ISC" - }, "node_modules/tar-stream": { "version": "2.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -42762,6 +40582,7 @@ "version": "3.6.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -43548,6 +41369,8 @@ }, "node_modules/type-is": { "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", "dependencies": { "media-typer": "0.3.0", @@ -43689,7 +41512,9 @@ } }, "node_modules/ufo": { - "version": "1.5.3", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", + "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", "dev": true, "license": "MIT" }, @@ -43765,6 +41590,8 @@ }, "node_modules/unicorn-magic": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", "dev": true, "license": "MIT", "engines": { @@ -43979,14 +41806,6 @@ "license": "MIT", "optional": true }, - "node_modules/untildify": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/upath": { "version": "1.2.0", "license": "MIT", @@ -44390,6 +42209,13 @@ "dev": true, "license": "MIT" }, + "node_modules/walk-up-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz", + "integrity": "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==", + "dev": true, + "license": "ISC" + }, "node_modules/walker": { "version": "1.0.8", "license": "Apache-2.0", diff --git a/package.json b/package.json index b36985b72631..be1b389616bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.29-6", + "version": "9.0.33-0", "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.", @@ -30,8 +30,8 @@ "createDocsRoutes": "ts-node .github/scripts/createDocsRoutes.ts", "detectRedirectCycle": "ts-node .github/scripts/detectRedirectCycle.ts", "desktop-build-adhoc": "scripts/build-desktop.sh adhoc", - "ios-build": "fastlane ios build", - "android-build": "fastlane android build", + "ios-build": "fastlane ios build_unsigned", + "android-build": "fastlane android build_local", "android-build-e2e": "bundle exec fastlane android build_e2e", "android-build-e2edelta": "bundle exec fastlane android build_e2edelta", "test": "TZ=utc NODE_OPTIONS=--experimental-vm-modules jest", @@ -100,7 +100,7 @@ "@react-navigation/native": "6.1.12", "@react-navigation/stack": "6.3.29", "@react-ng/bounds-observer": "^0.2.1", - "@rnmapbox/maps": "10.1.26", + "@rnmapbox/maps": "10.1.30", "@shopify/flash-list": "1.7.1", "@types/mime-db": "^1.43.5", "@ua/react-native-airship": "19.2.1", @@ -113,7 +113,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.83", + "expensify-common": "2.0.84", "expo": "51.0.17", "expo-av": "14.0.6", "expo-image": "1.12.12", @@ -153,7 +153,7 @@ "react-native-google-places-autocomplete": "2.5.6", "react-native-haptic-feedback": "^2.2.0", "react-native-image-picker": "^7.0.3", - "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#93399c6410de32966eb57085936ef6951398c2c3", + "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", "react-native-keyboard-controller": "^1.12.2", "react-native-launch-arguments": "^4.0.2", @@ -226,7 +226,7 @@ "@storybook/addon-a11y": "^8.1.10", "@storybook/addon-essentials": "^8.1.10", "@storybook/addon-webpack5-compiler-babel": "^3.0.3", - "@storybook/cli": "^8.1.10", + "@storybook/cli": "^8.3.0", "@storybook/react": "^8.1.10", "@storybook/react-webpack5": "^8.1.6", "@storybook/theming": "^8.1.10", @@ -289,6 +289,7 @@ "eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0", "html-webpack-plugin": "^5.5.0", "http-server": "^14.1.1", + "husky": "^9.1.5", "jest": "29.4.1", "jest-circus": "29.4.1", "jest-cli": "29.4.1", @@ -311,7 +312,7 @@ "setimmediate": "^1.0.5", "shellcheck": "^1.1.0", "source-map": "^0.7.4", - "storybook": "^8.1.10", + "storybook": "^8.3.0", "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", "ts-jest": "^29.1.2", diff --git a/patches/@rnmapbox+maps+10.1.26+001+rn-75-fixes.patch b/patches/@rnmapbox+maps+10.1.26+001+rn-75-fixes.patch deleted file mode 100644 index c8e3719e80d8..000000000000 --- a/patches/@rnmapbox+maps+10.1.26+001+rn-75-fixes.patch +++ /dev/null @@ -1,188 +0,0 @@ -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt -index bf149f9..2d3441b 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt -@@ -190,7 +190,7 @@ class RNMBXCamera(private val mContext: Context, private val mManager: RNMBXCame - - private fun setInitialCamera() { - mDefaultStop?.let { -- val mapView = mMapView!! -+ val mapView = mMapView ?: return - val map = mapView.getMapboxMap() - - it.setDuration(0) -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImagesManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImagesManager.kt -index 67c8656..248011f 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImagesManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImagesManager.kt -@@ -210,7 +210,7 @@ class RNMBXImagesManager(private val mContext: ReactApplicationContext) : - - // region RNMBXImage children - -- override fun addView(parent: RNMBXImages?, childView: View?, childPosition: Int) { -+ override fun addView(parent: RNMBXImages, childView: View, childPosition: Int) { - if (parent == null || childView == null) { - Logger.e("RNMBXImages", "addView: parent or childView is null") - return -@@ -225,7 +225,7 @@ class RNMBXImagesManager(private val mContext: ReactApplicationContext) : - childView.nativeImageUpdater = parent - } - -- override fun removeView(parent: RNMBXImages?, view: View?) { -+ override fun removeView(parent: RNMBXImages, view: View) { - if (parent == null || view == null) { - Logger.e("RNMBXImages", "removeView: parent or view is null") - return -@@ -234,7 +234,7 @@ class RNMBXImagesManager(private val mContext: ReactApplicationContext) : - parent.mImageViews.remove(view) - } - -- override fun removeAllViews(parent: RNMBXImages?) { -+ override fun removeAllViews(parent: RNMBXImages) { - if (parent == null) { - Logger.e("RNMBXImages", "removeAllViews parent is null") - return -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/NativeMapViewModule.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/NativeMapViewModule.kt -index ef529ef..4115802 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/NativeMapViewModule.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/NativeMapViewModule.kt -@@ -152,14 +152,6 @@ class NativeMapViewModule(context: ReactApplicationContext, val viewTagResolver: - } - } - -- public fun setHandledMapChangedEvents( -- viewRef: Double?, -- events: ReadableArray, -- promise: Promise -- ) { -- setHandledMapChangedEvents(viewRef?.toInt(), events, promise) -- } -- - override fun clearData(viewRef: ViewRefTag?, promise: Promise) { - withMapViewOnUIThread(viewRef, promise) { - it.clearData(createCommandResponse(promise)) -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt -index 98febe7..8601286 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt -@@ -86,19 +86,19 @@ open class RNMBXMapViewManager(context: ReactApplicationContext, val viewTagReso - } - } - -- override fun addView(mapView: RNMBXMapView?, childView: View?, childPosition: Int) { -+ override fun addView(mapView: RNMBXMapView, childView: View, childPosition: Int) { - mapView!!.addFeature(childView, childPosition) - } - -- override fun getChildCount(mapView: RNMBXMapView?): Int { -+ override fun getChildCount(mapView: RNMBXMapView): Int { - return mapView!!.featureCount - } - -- override fun getChildAt(mapView: RNMBXMapView?, index: Int): View? { -+ override fun getChildAt(mapView: RNMBXMapView, index: Int): View? { - return mapView!!.getFeatureAt(index) - } - -- override fun removeViewAt(mapView: RNMBXMapView?, index: Int) { -+ override fun removeViewAt(mapView: RNMBXMapView, index: Int) { - mapView!!.removeFeatureAt(index) - } - -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXImageSource.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXImageSource.kt -index be22072..602ca6d 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXImageSource.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXImageSource.kt -@@ -37,7 +37,7 @@ class RNMBXImageSource(context: Context?) : RNMBXSource<ImageSource?>(context) { - val uri = Uri.parse(url) - if (uri.scheme == null) { - mResourceId = -- ResourceDrawableIdHelper.getInstance().getResourceDrawableId(this.context, url) -+ ResourceDrawableIdHelper.instance.getResourceDrawableId(this.context, url) - if (mSource != null) { - throw RuntimeException("ImageSource Resource id not supported in v10") - } -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterDemSourceManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterDemSourceManager.kt -index c843d11..70a2c47 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterDemSourceManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterDemSourceManager.kt -@@ -11,10 +11,10 @@ import com.rnmapbox.rnmbx.utils.Logger - // import com.rnmapbox.rnmbx.components.annotation.RNMBXCallout; - // import com.rnmapbox.rnmbx.utils.ResourceUtils; - class RNMBXRasterDemSourceManager(private val mContext: ReactApplicationContext) : -- RNMBXTileSourceManager<RNMBXRasterDemSource?>( -+ RNMBXTileSourceManager<RNMBXRasterDemSource>( - mContext - ), RNMBXRasterDemSourceManagerInterface<RNMBXRasterDemSource> { -- override fun customEvents(): Map<String, String>? { -+ override fun customEvents(): Map<String, String> { - return MapBuilder.builder<String, String>() - .build() - } -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterSourceManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterSourceManager.kt -index 5bebc1b..893d757 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterSourceManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXRasterSourceManager.kt -@@ -8,7 +8,7 @@ import com.facebook.react.viewmanagers.RNMBXRasterSourceManagerInterface - import javax.annotation.Nonnull - - class RNMBXRasterSourceManager(reactApplicationContext: ReactApplicationContext) : -- RNMBXTileSourceManager<RNMBXRasterSource?>(reactApplicationContext), -+ RNMBXTileSourceManager<RNMBXRasterSource>(reactApplicationContext), - RNMBXRasterSourceManagerInterface<RNMBXRasterSource> { - @Nonnull - override fun getName(): String { -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXShapeSourceModule.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXShapeSourceModule.kt -index 6398497..03c1829 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXShapeSourceModule.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXShapeSourceModule.kt -@@ -44,8 +44,8 @@ class RNMBXShapeSourceModule(reactContext: ReactApplicationContext?, private val - override fun getClusterLeaves( - viewRef: ViewRefTag?, - featureJSON: String, -- number: Int, -- offset: Int, -+ number: Double, -+ offset: Double, - promise: Promise - ) { - withShapeSourceOnUIThread(viewRef, promise) { -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXTileSourceManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXTileSourceManager.kt -index 767d27b..5ebe505 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXTileSourceManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXTileSourceManager.kt -@@ -7,7 +7,7 @@ import com.facebook.react.bridge.ReadableType - import com.facebook.react.uimanager.annotations.ReactProp - import com.rnmapbox.rnmbx.components.AbstractEventEmitter - --abstract class RNMBXTileSourceManager<T : RNMBXTileSource<*>?> internal constructor( -+abstract class RNMBXTileSourceManager<T : RNMBXTileSource<*>> internal constructor( - reactApplicationContext: ReactApplicationContext - ) : AbstractEventEmitter<T>(reactApplicationContext) { - override fun getChildAt(source: T, childPosition: Int): View { -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXVectorSourceManager.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXVectorSourceManager.kt -index 63b1cfb..b0d3e88 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXVectorSourceManager.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXVectorSourceManager.kt -@@ -11,7 +11,7 @@ import com.rnmapbox.rnmbx.events.constants.eventMapOf - import javax.annotation.Nonnull - - class RNMBXVectorSourceManager(reactApplicationContext: ReactApplicationContext) : -- RNMBXTileSourceManager<RNMBXVectorSource?>(reactApplicationContext), -+ RNMBXTileSourceManager<RNMBXVectorSource>(reactApplicationContext), - RNMBXVectorSourceManagerInterface<RNMBXVectorSource> { - @Nonnull - override fun getName(): String { -diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/utils/ViewTagResolver.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/utils/ViewTagResolver.kt -index 07bac4d..f45cc25 100644 ---- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/utils/ViewTagResolver.kt -+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/utils/ViewTagResolver.kt -@@ -16,7 +16,7 @@ data class ViewTagWaiter<V>( - - const val LOG_TAG = "ViewTagResolver" - --typealias ViewRefTag = Int -+typealias ViewRefTag = Double - // see https://github.com/rnmapbox/maps/pull/3074 - open class ViewTagResolver(val context: ReactApplicationContext) { - private val createdViews: HashSet<Int> = hashSetOf<Int>() diff --git a/patches/date-fns-tz+2.0.0.patch b/patches/date-fns-tz+2.0.0.patch new file mode 100644 index 000000000000..aa88f1443a79 --- /dev/null +++ b/patches/date-fns-tz+2.0.0.patch @@ -0,0 +1,84 @@ +diff --git a/node_modules/date-fns-tz/_lib/tzTokenizeDate/index.js b/node_modules/date-fns-tz/_lib/tzTokenizeDate/index.js +index 9222a61..8540224 100644 +--- a/node_modules/date-fns-tz/_lib/tzTokenizeDate/index.js ++++ b/node_modules/date-fns-tz/_lib/tzTokenizeDate/index.js +@@ -59,20 +59,23 @@ function hackyOffset(dtf, date) { + + var dtfCache = {}; + ++// New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12` ++const testDateFormatted = new Intl.DateTimeFormat('en-US', { ++ hourCycle: 'h23', ++ timeZone: 'America/New_York', ++ year: 'numeric', ++ month: '2-digit', ++ day: '2-digit', ++ hour: '2-digit', ++ minute: '2-digit', ++ second: '2-digit', ++}).format(new Date('2014-06-25T04:00:00.123Z')) ++const hourCycleSupported = ++ testDateFormatted === '06/25/2014, 00:00:00' || ++ testDateFormatted === 'β06β/β25β/β2014β β00β:β00β:β00' ++ + function getDateTimeFormat(timeZone) { + if (!dtfCache[timeZone]) { +- // New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12` +- var testDateFormatted = new Intl.DateTimeFormat('en-US', { +- hour12: false, +- timeZone: 'America/New_York', +- year: 'numeric', +- month: 'numeric', +- day: '2-digit', +- hour: '2-digit', +- minute: '2-digit', +- second: '2-digit' +- }).format(new Date('2014-06-25T04:00:00.123Z')); +- var hourCycleSupported = testDateFormatted === '06/25/2014, 00:00:00' || testDateFormatted === 'β06β/β25β/β2014β β00β:β00β:β00'; + dtfCache[timeZone] = hourCycleSupported ? new Intl.DateTimeFormat('en-US', { + hour12: false, + timeZone: timeZone, +diff --git a/node_modules/date-fns-tz/esm/_lib/tzTokenizeDate/index.js b/node_modules/date-fns-tz/esm/_lib/tzTokenizeDate/index.js +index cc1d143..17333cc 100644 +--- a/node_modules/date-fns-tz/esm/_lib/tzTokenizeDate/index.js ++++ b/node_modules/date-fns-tz/esm/_lib/tzTokenizeDate/index.js +@@ -48,23 +48,24 @@ function hackyOffset(dtf, date) { + // to get deterministic local date/time output according to the `en-US` locale which + // can be used to extract local time parts as necessary. + var dtfCache = {} ++ ++// New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12` ++const testDateFormatted = new Intl.DateTimeFormat('en-US', { ++ hourCycle: 'h23', ++ timeZone: 'America/New_York', ++ year: 'numeric', ++ month: '2-digit', ++ day: '2-digit', ++ hour: '2-digit', ++ minute: '2-digit', ++ second: '2-digit', ++}).format(new Date('2014-06-25T04:00:00.123Z')) ++const hourCycleSupported = ++ testDateFormatted === '06/25/2014, 00:00:00' || ++ testDateFormatted === 'β06β/β25β/β2014β β00β:β00β:β00' ++ + function getDateTimeFormat(timeZone) { + if (!dtfCache[timeZone]) { +- // New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12` +- var testDateFormatted = new Intl.DateTimeFormat('en-US', { +- hour12: false, +- timeZone: 'America/New_York', +- year: 'numeric', +- month: 'numeric', +- day: '2-digit', +- hour: '2-digit', +- minute: '2-digit', +- second: '2-digit', +- }).format(new Date('2014-06-25T04:00:00.123Z')) +- var hourCycleSupported = +- testDateFormatted === '06/25/2014, 00:00:00' || +- testDateFormatted === 'β06β/β25β/β2014β β00β:β00β:β00' +- + dtfCache[timeZone] = hourCycleSupported + ? new Intl.DateTimeFormat('en-US', { + hour12: false, diff --git a/src/CONFIG.ts b/src/CONFIG.ts index a1a72b86fadd..047d4dc823fd 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -97,5 +97,5 @@ export default { }, GCP_GEOLOCATION_API_KEY: googleGeolocationAPIKey, // to read more about StrictMode see: contributingGuides/STRICT_MODE.md - USE_REACT_STRICT_MODE_IN_DEV: true, + USE_REACT_STRICT_MODE_IN_DEV: false, } as const; diff --git a/src/CONST.ts b/src/CONST.ts index 15b3dab82cde..0778478c2950 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -4,6 +4,7 @@ import dateSubtract from 'date-fns/sub'; import Config from 'react-native-config'; import * as KeyCommand from 'react-native-key-command'; import type {ValueOf} from 'type-fest'; +import type {Video} from './libs/actions/Report'; import BankAccount from './libs/models/BankAccount'; import * as Url from './libs/Url'; import SCREENS from './SCREENS'; @@ -64,16 +65,91 @@ const chatTypes = { // Explicit type annotation is required const cardActiveStates: number[] = [2, 3, 4, 7]; -const onboardingChoices = { +const selectableOnboardingChoices = { PERSONAL_SPEND: 'newDotPersonalSpend', MANAGE_TEAM: 'newDotManageTeam', EMPLOYER: 'newDotEmployer', CHAT_SPLIT: 'newDotSplitChat', LOOKING_AROUND: 'newDotLookingAround', +} as const; + +const backendOnboardingChoices = { + SUBMIT: 'newDotSubmit', +} as const; + +const onboardingChoices = { + ...selectableOnboardingChoices, + ...backendOnboardingChoices, +} as const; + +const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { + message: 'Getting paid back is as easy as sending a message. Letβs go over the basics.', + video: { + url: `${CLOUDFRONT_URL}/videos/guided-setup-get-paid-back-v2.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-get-paid-back.jpg`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'submitExpense', + autoCompleted: false, + title: 'Submit an expense', + description: + '*Submit an expense* by entering an amount or scanning a receipt.\n' + + '\n' + + 'Hereβs how to submit an expense:\n' + + '\n' + + '1. Click the green *+* button.\n' + + '2. Choose *Submit expense*.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Add your reimburser to the request.\n' + + '\n' + + 'Then, send your request and wait for that sweet βCha-ching!β when itβs complete.', + }, + { + type: 'enableWallet', + autoCompleted: false, + title: 'Enable your wallet', + description: + 'Youβll need to *enable your Expensify Wallet* to get paid back. Donβt worry, itβs easy!\n' + + '\n' + + 'Hereβs how to set up your wallet:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click *Wallet* > *Enable wallet*.\n' + + '3. Connect your bank account.\n' + + '\n' + + 'Once thatβs done, you can request money from anyone and get paid back right into your personal bank account.', + }, + ], }; type OnboardingPurposeType = ValueOf<typeof onboardingChoices>; +const onboardingInviteTypes = { + IOU: 'iou', + INVOICE: 'invoice', + CHAT: 'chat', +} as const; + +type OnboardingInviteType = ValueOf<typeof onboardingInviteTypes>; + +type OnboardingTaskType = { + type: string; + autoCompleted: boolean; + title: string; + description: string | ((params: Partial<{adminsRoomLink: string; workspaceCategoriesLink: string; workspaceMoreFeaturesLink: string; workspaceMembersLink: string}>) => string); +}; + +type OnboardingMessageType = { + message: string; + video?: Video; + tasks: OnboardingTaskType[]; + type?: string; +}; + const CONST = { HEIC_SIGNATURES: [ '6674797068656963', // 'ftypheic' - Indicates standard HEIC file @@ -385,7 +461,6 @@ const CONST = { DEFAULT_ROOMS: 'defaultRooms', DUPE_DETECTION: 'dupeDetection', P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', - WORKFLOWS_ADVANCED_APPROVAL: 'workflowsAdvancedApproval', SPOTNANA_TRAVEL: 'spotnanaTravel', REPORT_FIELDS_FEATURE: 'reportFieldsFeature', WORKSPACE_FEEDS: 'workspaceFeeds', @@ -639,7 +714,9 @@ const CONST = { SAGE_INTACCT_HELP_LINK: "https://help.expensify.com/articles/expensify-classic/connections/sage-intacct/Sage-Intacct-Troubleshooting#:~:text=First%20make%20sure%20that%20you,your%20company's%20Web%20Services%20authorizations.", PRICING: `https://www.expensify.com/pricing`, - + COMPANY_CARDS_HELP: 'https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds', + CUSTOM_REPORT_NAME_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates', + COPILOT_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot', // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', OLDDOT_URLS: { @@ -748,6 +825,7 @@ const CONST = { REIMBURSEMENT_SETUP: 'REIMBURSEMENTSETUP', // Deprecated OldDot Action REIMBURSEMENT_SETUP_REQUESTED: 'REIMBURSEMENTSETUPREQUESTED', // Deprecated OldDot Action REJECTED: 'REJECTED', + REMOVED_FROM_APPROVAL_CHAIN: 'REMOVEDFROMAPPROVALCHAIN', RENAMED: 'RENAMED', REPORT_PREVIEW: 'REPORTPREVIEW', SELECTED_FOR_RANDOM_AUDIT: 'SELECTEDFORRANDOMAUDIT', // OldDot Action @@ -804,6 +882,7 @@ const CONST = { UPDATE_AUTO_REPORTING_FREQUENCY: 'POLICYCHANGELOG_UPDATE_AUTOREPORTING_FREQUENCY', UPDATE_BUDGET: 'POLICYCHANGELOG_UPDATE_BUDGET', UPDATE_CATEGORY: 'POLICYCHANGELOG_UPDATE_CATEGORY', + UPDATE_CATEGORIES: 'POLICYCHANGELOG_UPDATE_CATEGORIES', UPDATE_CURRENCY: 'POLICYCHANGELOG_UPDATE_CURRENCY', UPDATE_CUSTOM_UNIT: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT', UPDATE_CUSTOM_UNIT_RATE: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT_RATE', @@ -866,6 +945,7 @@ const CONST = { ACCOUNT_MERGED: 'accountMerged', REMOVED_FROM_POLICY: 'removedFromPolicy', POLICY_DELETED: 'policyDeleted', + INVOICE_RECEIVER_POLICY_DELETED: 'invoiceReceiverPolicyDeleted', BOOKING_END_DATE_HAS_PASSED: 'bookingEndDateHasPassed', }, MESSAGE: { @@ -942,6 +1022,9 @@ const CONST = { EXPORT_TO_INTEGRATION: 'exportToIntegration', MARK_AS_EXPORTED: 'markAsExported', }, + ROOM_MEMBERS_BULK_ACTION_TYPES: { + REMOVE: 'remove', + }, }, NEXT_STEP: { ICONS: { @@ -1262,6 +1345,7 @@ const CONST = { ATTACHMENT_TYPE: { REPORT: 'r', NOTE: 'n', + SEARCH: 's', }, IMAGE_HIGH_RESOLUTION_THRESHOLD: 7000, @@ -1362,21 +1446,25 @@ const CONST = { }, QUICKBOOKS_ONLINE: 'quickbooksOnline', - QUICK_BOOKS_CONFIG: { - SYNC_CLASSES: 'syncClasses', + QUICKBOOKS_CONFIG: { ENABLE_NEW_CATEGORIES: 'enableNewCategories', + SYNC_CLASSES: 'syncClasses', SYNC_CUSTOMERS: 'syncCustomers', SYNC_LOCATIONS: 'syncLocations', SYNC_TAX: 'syncTax', EXPORT: 'export', + EXPORTER: 'exporter', EXPORT_DATE: 'exportDate', NON_REIMBURSABLE_EXPENSES_ACCOUNT: 'nonReimbursableExpensesAccount', NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'nonReimbursableExpensesExportDestination', REIMBURSABLE_EXPENSES_ACCOUNT: 'reimbursableExpensesAccount', REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'reimbursableExpensesExportDestination', NON_REIMBURSABLE_BILL_DEFAULT_VENDOR: 'nonReimbursableBillDefaultVendor', + NON_REIMBURSABLE_EXPENSE_EXPORT_DESTINATION: 'nonReimbursableExpensesExportDestination', + NON_REIMBURSABLE_EXPENSE_ACCOUNT: 'nonReimbursableExpensesAccount', RECEIVABLE_ACCOUNT: 'receivableAccount', AUTO_SYNC: 'autoSync', + ENABLED: 'enabled', SYNC_PEOPLE: 'syncPeople', AUTO_CREATE_VENDOR: 'autoCreateVendor', REIMBURSEMENT_ACCOUNT_ID: 'reimbursementAccountID', @@ -1914,6 +2002,11 @@ const CONST = { BUSINESS_BANK_ACCOUNT: 'businessBankAccount', }, + PAYMENT_SELECTED: { + BBA: 'BBA', + PBA: 'PBA', + }, + PAYMENT_METHOD_ID_KEYS: { DEBIT_CARD: 'fundID', BANK_ACCOUNT: 'bankAccountID', @@ -1988,6 +2081,10 @@ const CONST = { ACCESS_VARIANTS: { CREATE: 'create', }, + PAYMENT_SELECTED: { + BBA: 'BBA', + PBA: 'PBA', + }, }, GROWL: { @@ -2050,11 +2147,18 @@ const CONST = { // Often referred to as "collect" workspaces TEAM: 'team', }, + FIELD_LIST_TITLE_FIELD_ID: 'text_title', + DEFAULT_REPORT_NAME_PATTERN: '{report:type} {report:startdate}', ROLE: { ADMIN: 'admin', AUDITOR: 'auditor', USER: 'user', }, + AUTO_REIMBURSEMENT_MAX_LIMIT_CENTS: 2000000, + AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS: 10000, + AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS: 10000, + RANDOM_AUDIT_DEFAULT_PERCENTAGE: 5, + AUTO_REPORTING_FREQUENCIES: { INSTANT: 'instant', IMMEDIATE: 'immediate', @@ -2240,6 +2344,15 @@ const CONST = { DEFAULT_MAX_EXPENSE_AGE: 90, DEFAULT_MAX_EXPENSE_AMOUNT: 200000, DEFAULT_MAX_AMOUNT_NO_RECEIPT: 2500, + REQUIRE_RECEIPTS_OVER_OPTIONS: { + DEFAULT: 'default', + NEVER: 'never', + ALWAYS: 'always', + }, + EXPENSE_LIMIT_TYPES: { + EXPENSE: 'expense', + DAILY: 'daily', + }, }, CUSTOM_UNITS: { @@ -2346,6 +2459,23 @@ const CONST = { }, CARD_TITLE_INPUT_LIMIT: 255, }, + COMPANY_CARDS: { + STEP: { + CARD_TYPE: 'CardType', + CARD_INSTRUCTIONS: 'CardInstructions', + CARD_NAME: 'CardName', + CARD_DETAILS: 'CardDetails', + }, + CARD_TYPE: { + AMEX: 'amex', + VISA: 'visa', + MASTERCARD: 'mastercard', + }, + DELETE_TRANSACTIONS: { + RESTRICT: 'corporate', + ALLOW: 'personal', + }, + }, AVATAR_ROW_SIZE: { DEFAULT: 4, LARGE_SCREEN: 8, @@ -2360,12 +2490,6 @@ const CONST = { PAYPERUSE: 'monthly2018', }, }, - COMPANY_CARDS: { - DELETE_TRANSACTIONS: { - RESTRICT: 'corporate', - ALLOW: 'personal', - }, - }, REGEX: { SPECIAL_CHARS_WITHOUT_NEWLINE: /((?!\n)[()-\s\t])/g, DIGITS_AND_PLUS: /^\+?[0-9]*$/, @@ -3975,9 +4099,10 @@ const CONST = { GETCODE: 'GETCODE', }, DELEGATE_ROLE: { - SUBMITTER: 'submitter', ALL: 'all', + SUBMITTER: 'submitter', }, + DELEGATE_ROLE_HELPDOT_ARTICLE_LINK: 'https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/', STRIPE_GBP_AUTH_STATUSES: { SUCCEEDED: 'succeeded', CARD_AUTHENTICATION_REQUIRED: 'authentication_required', @@ -4107,6 +4232,11 @@ const CONST = { */ MAX_SELECTION_LIST_PAGE_LENGTH: 500, + /** + * We only include the members search bar when we have 8 or more members + */ + SHOULD_SHOW_MEMBERS_SEARCH_INPUT_BREAKPOINT: 8, + /** * Bank account names */ @@ -4282,6 +4412,8 @@ const CONST = { ONBOARDING_INTRODUCTION: 'Letβs get you set up π§', ONBOARDING_CHOICES: {...onboardingChoices}, + SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices}, + ONBOARDING_INVITE_TYPES: {...onboardingInviteTypes}, ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE: 'What would you like to do with this expense?', ONBOARDING_CONCIERGE: { [onboardingChoices.EMPLOYER]: @@ -4324,49 +4456,8 @@ const CONST = { }, ONBOARDING_MESSAGES: { - [onboardingChoices.EMPLOYER]: { - message: 'Getting paid back is as easy as sending a message. Letβs go over the basics.', - video: { - url: `${CLOUDFRONT_URL}/videos/guided-setup-get-paid-back-v2.mp4`, - thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-get-paid-back.jpg`, - duration: 55, - width: 1280, - height: 960, - }, - tasks: [ - { - type: 'submitExpense', - autoCompleted: false, - title: 'Submit an expense', - description: - '*Submit an expense* by entering an amount or scanning a receipt.\n' + - '\n' + - 'Hereβs how to submit an expense:\n' + - '\n' + - '1. Click the green *+* button.\n' + - '2. Choose *Submit expense*.\n' + - '3. Enter an amount or scan a receipt.\n' + - '4. Add your reimburser to the request.\n' + - '\n' + - 'Then, send your request and wait for that sweet βCha-ching!β when itβs complete.', - }, - { - type: 'enableWallet', - autoCompleted: false, - title: 'Enable your wallet', - description: - 'Youβll need to *enable your Expensify Wallet* to get paid back. Donβt worry, itβs easy!\n' + - '\n' + - 'Hereβs how to set up your wallet:\n' + - '\n' + - '1. Click your profile picture.\n' + - '2. Click *Wallet* > *Enable wallet*.\n' + - '3. Connect your bank account.\n' + - '\n' + - 'Once thatβs done, you can request money from anyone and get paid back right into your personal bank account.', - }, - ], - }, + [onboardingChoices.EMPLOYER]: onboardingEmployerOrSubmitMessage, + [onboardingChoices.SUBMIT]: onboardingEmployerOrSubmitMessage, [onboardingChoices.MANAGE_TEAM]: { message: 'Here are some important tasks to help get your teamβs expenses under control.', video: { @@ -4395,7 +4486,7 @@ const CONST = { type: 'meetGuide', autoCompleted: false, title: 'Meet your setup specialist', - description: ({adminsRoomLink}: {adminsRoomLink: string}) => + description: ({adminsRoomLink}) => `Meet your setup specialist, who can answer any questions as you get started with Expensify. Yes, a real human!\n` + '\n' + `Chat with the specialist in your [#admins room](${adminsRoomLink}).`, @@ -4404,52 +4495,57 @@ const CONST = { type: 'setupCategories', autoCompleted: false, title: 'Set up categories', - description: ({workspaceLink}: {workspaceLink: string}) => + description: ({workspaceCategoriesLink}) => '*Set up categories* so your team can code expenses for easy reporting.\n' + '\n' + 'Hereβs how to set up categories:\n' + '\n' + '1. Click your profile picture.\n' + - `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + - '3. Click *Categories*.\n' + - '4. Enable and disable default categories.\n' + - '5. Click *Add categories* to make your own.\n' + + '2. Go to Workspaces.\n' + + '3. Select your workspace.\n' + + '4. Click *Categories*.\n' + + '5. Enable and disable default categories.\n' + + '6. Click *Add categories* to make your own.\n' + + '7. For more controls like requiring a category for every expense, click *Settings*.\n' + '\n' + - 'For more controls like requiring a category for every expense, click *Settings*.', + `[Take me to workspace category settings](${workspaceCategoriesLink}).`, }, { type: 'addExpenseApprovals', autoCompleted: false, title: 'Add expense approvals', - description: ({workspaceLink}: {workspaceLink: string}) => + description: ({workspaceMoreFeaturesLink}) => '*Add expense approvals* to review your teamβs spend and keep it under control.\n' + '\n' + 'Hereβs how to add expense approvals:\n' + '\n' + '1. Click your profile picture.\n' + - `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + - '3. Click *More features*.\n' + - '4. Enable *Workflows*.\n' + - '5. In *Workflows*, enable *Add approvals*.\n' + + '2. Go to Workspaces.\n' + + '3. Select your workspace.\n' + + '4. Click *More features*.\n' + + '5. Enable *Workflows*.\n' + + '6. In *Workflows*, enable *Add approvals*.\n' + + '7. Youβll be set as the expense approver. You can change this to any admin once you invite your team.\n' + '\n' + - 'Youβll be set as the expense approver. You can change this to any admin once you invite your team.', + `[Take me to enable more features](${workspaceMoreFeaturesLink}).`, }, { type: 'inviteTeam', autoCompleted: false, title: 'Invite your team', - description: ({workspaceLink}: {workspaceLink: string}) => + description: ({workspaceMembersLink}) => '*Invite your team* to Expensify so they can start tracking expenses today.\n' + '\n' + 'Hereβs how to invite your team:\n' + '\n' + '1. Click your profile picture.\n' + - `2. Go to [*Workspaces* > [your workspace]](${workspaceLink}).\n` + - '3. Click *Members* > *Invite member*.\n' + - '4. Enter emails or phone numbers. \n' + - '5. Add an invite message if you want.\n' + + '2. Go to Workspaces.\n' + + '3. Select your workspace.\n' + + '4. Click *Members* > *Invite member*.\n' + + '5. Enter emails or phone numbers. \n' + + '6. Add an invite message if you want.\n' + '\n' + - 'Thatβs it! Happy expensing :)', + `[Take me to workspace members](${workspaceMembersLink}). Thatβs it, happy expensing! :)`, }, ], }, @@ -4546,7 +4642,7 @@ const CONST = { "Expensify is best known for expense and corporate card management, but we do a lot more than that. Let me know what you're interested in and I'll help get you started.", tasks: [], }, - }, + } satisfies Record<OnboardingPurposeType, OnboardingMessageType>, REPORT_FIELD_TITLE_FIELD_ID: 'text_title', @@ -5360,15 +5456,16 @@ const CONST = { }, TRIP: { ALL: 'all', - DRAFTS: 'drafts', - OUTSTANDING: 'outstanding', - APPROVED: 'approved', - PAID: 'paid', + CURRENT: 'current', + PAST: 'past', + }, + CHAT: { + ALL: 'all', + UNREAD: 'unread', + SENT: 'sent', + ATTACHMENTS: 'attachments', + LINKS: 'links', }, - }, - CHAT_TYPES: { - LINK: 'link', - ATTACHMENT: 'attachment', }, TABLE_COLUMNS: { RECEIPT: 'receipt', @@ -5417,7 +5514,6 @@ const CONST = { REPORT_ID: 'reportID', KEYWORD: 'keyword', IN: 'in', - HAS: 'has', }, }, @@ -5618,6 +5714,6 @@ type FeedbackSurveyOptionID = ValueOf<Pick<ValueOf<typeof CONST.FEEDBACK_SURVEY_ type SubscriptionType = ValueOf<typeof CONST.SUBSCRIPTION.TYPE>; type CancellationType = ValueOf<typeof CONST.CANCELLATION_TYPE>; -export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType}; +export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType, OnboardingInviteType}; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d2a0372fd9c7..e84d77ee30c9 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,5 +1,6 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; +import type {OnboardingPurposeType} from './CONST'; import type * as FormTypes from './types/form'; import type * as OnyxTypes from './types/onyx'; import type Onboarding from './types/onyx/Onboarding'; @@ -105,6 +106,9 @@ const ONYXKEYS = { /** Object containing contact method that's going to be added */ PENDING_CONTACT_ACTION: 'pendingContactAction', + /** Store the information of magic code */ + VALIDATE_ACTION_CODE: 'validate_action_code', + /** Information about the current session (authToken, accountID, email, loading, error) */ SESSION: 'session', STASHED_SESSION: 'stashedSession', @@ -211,6 +215,9 @@ const ONYXKEYS = { /** The NVP containing all information related to educational tooltip in workspace chat */ NVP_WORKSPACE_TOOLTIP: 'workspaceTooltip', + /** Whether to hide gbr tooltip */ + NVP_SHOULD_HIDE_GBR_TOOLTIP: 'nvp_should_hide_gbr_tooltip', + /** Does this user have push notifications enabled for this device? */ PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', @@ -336,6 +343,9 @@ const ONYXKEYS = { /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_ADMINS_CHAT_REPORT_ID: 'onboardingAdminsChatReportID', + // Stores onboarding last visited path + ONBOARDING_LAST_VISITED_PATH: 'onboardingLastVisitedPath', + // Max width supported for HTML <canvas> element MAX_CANVAS_WIDTH: 'maxCanvasWidth', @@ -392,6 +402,9 @@ const ONYXKEYS = { /** Stores the information about the state of issuing a new card */ ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard', + /** Stores the information about the state of addint a new company card */ + ADD_NEW_COMPANY_CARD: 'addNewCompanyCard', + /** Stores the information about the state of assigning a company card */ ASSIGN_CARD: 'assignCard', @@ -403,6 +416,8 @@ const ONYXKEYS = { /** Stores the information about currently edited advanced approval workflow */ APPROVAL_WORKFLOW: 'approvalWorkflow', + /** Stores the user search value for persistance across the screens */ + ROOM_MEMBERS_USER_SEARCH_PHRASE: 'roomMembersUserSearchPhrase', /** Stores information about recently uploaded spreadsheet file */ IMPORTED_SPREADSHEET: 'importedSpreadsheet', @@ -497,6 +512,10 @@ const ONYXKEYS = { WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', WORKSPACE_CATEGORY_FORM: 'workspaceCategoryForm', WORKSPACE_CATEGORY_FORM_DRAFT: 'workspaceCategoryFormDraft', + WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM: 'workspaceCategoryDescriptionHintForm', + WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM_DRAFT: 'workspaceCategoryDescriptionHintFormDraft', + WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER_FORM: 'workspaceCategoryFlagAmountsOverForm', + WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER_FORM_DRAFT: 'workspaceCategoryFlagAmountsOverFormDraft', WORKSPACE_TAG_FORM: 'workspaceTagForm', WORKSPACE_TAG_FORM_DRAFT: 'workspaceTagFormDraft', WORKSPACE_SETTINGS_FORM_DRAFT: 'workspaceSettingsFormDraft', @@ -618,6 +637,8 @@ const ONYXKEYS = { SUBSCRIPTION_SIZE_FORM_DRAFT: 'subscriptionSizeFormDraft', ISSUE_NEW_EXPENSIFY_CARD_FORM: 'issueNewExpensifyCard', ISSUE_NEW_EXPENSIFY_CARD_FORM_DRAFT: 'issueNewExpensifyCardDraft', + ADD_NEW_CARD_FEED_FORM: 'addNewCardFeed', + ADD_NEW_CARD_FEED_FORM_DRAFT: 'addNewCardFeedDraft', ASSIGN_CARD_FORM: 'assignCard', ASSIGN_CARD_FORM_DRAFT: 'assignCardDraft', EDIT_EXPENSIFY_CARD_NAME_FORM: 'editExpensifyCardName', @@ -642,6 +663,14 @@ const ONYXKEYS = { SEARCH_ADVANCED_FILTERS_FORM_DRAFT: 'searchAdvancedFiltersFormDraft', TEXT_PICKER_MODAL_FORM: 'textPickerModalForm', TEXT_PICKER_MODAL_FORM_DRAFT: 'textPickerModalFormDraft', + RULES_CUSTOM_NAME_MODAL_FORM: 'rulesCustomNameModalForm', + RULES_CUSTOM_NAME_MODAL_FORM_DRAFT: 'rulesCustomNameModalFormDraft', + RULES_AUTO_APPROVE_REPORTS_UNDER_MODAL_FORM: 'rulesAutoApproveReportsUnderModalForm', + RULES_AUTO_APPROVE_REPORTS_UNDER_MODAL_FORM_DRAFT: 'rulesAutoApproveReportsUnderModalFormDraft', + RULES_RANDOM_REPORT_AUDIT_MODAL_FORM: 'rulesRandomReportAuditModalForm', + RULES_RANDOM_REPORT_AUDIT_MODAL_FORM_DRAFT: 'rulesRandomReportAuditModalFormDraft', + RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM: 'rulesAutoPayReportsUnderModalForm', + RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM_DRAFT: 'rulesAutoPayReportsUnderModalFormDraft', RULES_REQUIRED_RECEIPT_AMOUNT_FORM: 'rulesRequiredReceiptAmountForm', RULES_REQUIRED_RECEIPT_AMOUNT_FORM_DRAFT: 'rulesRequiredReceiptAmountFormDraft', RULES_MAX_EXPENSE_AMOUNT_FORM: 'rulesMaxExpenseAmountForm', @@ -662,6 +691,8 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName; [ONYXKEYS.FORMS.WORKSPACE_COMPANY_CARD_FEED_NAME]: FormTypes.WorkspaceCompanyCardFeedName; [ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM]: FormTypes.WorkspaceReportFieldForm; + [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM]: FormTypes.WorkspaceCategoryDescriptionHintForm; + [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER_FORM]: FormTypes.WorkspaceCategoryFlagAmountsOverForm; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; @@ -717,6 +748,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.NEW_CHAT_NAME_FORM]: FormTypes.NewChatNameForm; [ONYXKEYS.FORMS.SUBSCRIPTION_SIZE_FORM]: FormTypes.SubscriptionSizeForm; [ONYXKEYS.FORMS.ISSUE_NEW_EXPENSIFY_CARD_FORM]: FormTypes.IssueNewExpensifyCardForm; + [ONYXKEYS.FORMS.ADD_NEW_CARD_FEED_FORM]: FormTypes.AddNewCardFeedForm; [ONYXKEYS.FORMS.ASSIGN_CARD_FORM]: FormTypes.AssignCardForm; [ONYXKEYS.FORMS.EDIT_EXPENSIFY_CARD_NAME_FORM]: FormTypes.EditExpensifyCardNameForm; [ONYXKEYS.FORMS.EDIT_EXPENSIFY_CARD_LIMIT_FORM]: FormTypes.EditExpensifyCardLimitForm; @@ -729,6 +761,10 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.SAGE_INTACCT_DIMENSION_TYPE_FORM]: FormTypes.SageIntacctDimensionForm; [ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM]: FormTypes.SearchAdvancedFiltersForm; [ONYXKEYS.FORMS.TEXT_PICKER_MODAL_FORM]: FormTypes.TextPickerModalForm; + [ONYXKEYS.FORMS.RULES_CUSTOM_NAME_MODAL_FORM]: FormTypes.RulesCustomNameModalForm; + [ONYXKEYS.FORMS.RULES_AUTO_APPROVE_REPORTS_UNDER_MODAL_FORM]: FormTypes.RulesAutoApproveReportsUnderModalForm; + [ONYXKEYS.FORMS.RULES_RANDOM_REPORT_AUDIT_MODAL_FORM]: FormTypes.RulesRandomReportAuditModalForm; + [ONYXKEYS.FORMS.RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM]: FormTypes.RulesAutoPayReportsUnderModalForm; [ONYXKEYS.FORMS.RULES_REQUIRED_RECEIPT_AMOUNT_FORM]: FormTypes.RulesRequiredReceiptAmountForm; [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AMOUNT_FORM]: FormTypes.RulesMaxExpenseAmountForm; [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm; @@ -824,6 +860,7 @@ type OnyxValuesMapping = { [ONYXKEYS.USER_LOCATION]: OnyxTypes.UserLocation; [ONYXKEYS.LOGIN_LIST]: OnyxTypes.LoginList; [ONYXKEYS.PENDING_CONTACT_ACTION]: OnyxTypes.PendingContactAction; + [ONYXKEYS.VALIDATE_ACTION_CODE]: OnyxTypes.ValidateMagicCodeAction; [ONYXKEYS.SESSION]: OnyxTypes.Session; [ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata; [ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session; @@ -890,10 +927,11 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_AREA]: number; [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; - [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string; + [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: OnboardingPurposeType; [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; + [ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string; [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; @@ -914,6 +952,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_TRAVEL_SETTINGS]: OnyxTypes.TravelSettings; [ONYXKEYS.REVIEW_DUPLICATES]: OnyxTypes.ReviewDuplicates; [ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; + [ONYXKEYS.ADD_NEW_COMPANY_CARD]: OnyxTypes.AddNewCompanyCardFeed; [ONYXKEYS.ASSIGN_CARD]: OnyxTypes.AssignCard; [ONYXKEYS.MOBILE_SELECTION_MODE]: OnyxTypes.MobileSelectionMode; [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: string; @@ -922,7 +961,9 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; [ONYXKEYS.NVP_WORKSPACE_TOOLTIP]: OnyxTypes.WorkspaceTooltip; + [ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP]: boolean; [ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS]: OnyxTypes.CancellationDetails[]; + [ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE]: string; [ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx; [ONYXKEYS.IMPORTED_SPREADSHEET]: OnyxTypes.ImportedSpreadsheet; [ONYXKEYS.LAST_ROUTE]: string; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 89023063ad8f..059599c3997a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -35,7 +35,7 @@ const ROUTES = { SEARCH_CENTRAL_PANE: { route: 'search', - getRoute: ({query}: {query: SearchQueryString}) => `search?q=${query}` as const, + getRoute: ({query}: {query: SearchQueryString}) => `search?q=${encodeURIComponent(query)}` as const, }, SEARCH_ADVANCED_FILTERS: 'search/filters', SEARCH_ADVANCED_FILTERS_DATE: 'search/filters/date', @@ -53,11 +53,14 @@ const ROUTES = { SEARCH_ADVANCED_FILTERS_FROM: 'search/filters/from', SEARCH_ADVANCED_FILTERS_TO: 'search/filters/to', SEARCH_ADVANCED_FILTERS_IN: 'search/filters/in', - SEARCH_ADVANCED_FILTERS_HAS: 'search/filters/has', - SEARCH_REPORT: { - route: 'search/view/:reportID', - getRoute: (reportID: string) => `search/view/${reportID}` as const, + route: 'search/view/:reportID/:reportActionID?', + getRoute: (reportID: string, reportActionID?: string) => { + if (reportActionID) { + return `search/view/${reportID}/${reportActionID}` as const; + } + return `search/view/${reportID}` as const; + }, }, TRANSACTION_HOLD_REASON_RHP: 'search/hold', @@ -128,6 +131,19 @@ const ROUTES = { SETTINGS_WORKSPACES: 'settings/workspaces', SETTINGS_SECURITY: 'settings/security', SETTINGS_CLOSE: 'settings/security/closeAccount', + SETTINGS_ADD_DELEGATE: 'settings/security/delegate', + SETTINGS_DELEGATE_ROLE: { + route: 'settings/security/delegate/:login/role/:role', + getRoute: (login: string, role?: string) => `settings/security/delegate/${encodeURIComponent(login)}/role/${role}` as const, + }, + SETTINGS_DELEGATE_CONFIRM: { + route: 'settings/security/delegate/:login/role/:role/confirm', + getRoute: (login: string, role: string) => `settings/security/delegate/${encodeURIComponent(login)}/role/${role}/confirm` as const, + }, + SETTINGS_DELEGATE_MAGIC_CODE: { + route: 'settings/security/delegate/:login/role/:role/magic-code', + getRoute: (login: string, role: string) => `settings/security/delegate/${encodeURIComponent(login)}/role/${role}/magic-code` as const, + }, SETTINGS_ABOUT: 'settings/about', SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links', SETTINGS_WALLET: 'settings/wallet', @@ -350,9 +366,16 @@ const ROUTES = { route: 'r/:reportID/members', getRoute: (reportID: string) => `r/${reportID}/members` as const, }, + ROOM_MEMBER_DETAILS: { + route: 'r/:reportID/members/:accountID', + getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/members/${accountID}` as const, + }, ROOM_INVITE: { route: 'r/:reportID/invite/:role?', - getRoute: (reportID: string, role?: string) => `r/${reportID}/invite/${role ?? ''}` as const, + getRoute: (reportID: string, role?: string) => { + const route = role ? (`r/${reportID}/invite/${role}` as const) : (`r/${reportID}/invite` as const); + return route; + }, }, MONEY_REQUEST_HOLD_REASON: { route: ':type/edit/reason/:transactionID?', @@ -643,8 +666,8 @@ const ROUTES = { }, WORKSPACE_WORKFLOWS_APPROVALS_APPROVER: { route: 'settings/workspaces/:policyID/workflows/approvals/approver', - getRoute: (policyID: string, approverIndex?: number, backTo?: string) => - getUrlWithBackToParam(`settings/workspaces/${policyID}/workflows/approvals/approver${approverIndex !== undefined ? `?approverIndex=${approverIndex}` : ''}` as const, backTo), + getRoute: (policyID: string, approverIndex: number, backTo?: string) => + getUrlWithBackToParam(`settings/workspaces/${policyID}/workflows/approvals/approver?approverIndex=${approverIndex}` as const, backTo), }, WORKSPACE_WORKFLOWS_PAYER: { route: 'settings/workspaces/:policyID/workflows/payer', @@ -704,7 +727,19 @@ const ROUTES = { }, POLICY_ACCOUNTING: { route: 'settings/workspaces/:policyID/accounting', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting` as const, + getRoute: (policyID: string, newConnectionName?: ConnectionName, integrationToDisconnect?: ConnectionName, shouldDisconnectIntegrationBeforeConnecting?: boolean) => { + let queryParams = ''; + if (newConnectionName) { + queryParams += `?newConnectionName=${newConnectionName}`; + if (integrationToDisconnect) { + queryParams += `&integrationToDisconnect=${integrationToDisconnect}`; + } + if (shouldDisconnectIntegrationBeforeConnecting !== undefined) { + queryParams += `&shouldDisconnectIntegrationBeforeConnecting=${shouldDisconnectIntegrationBeforeConnecting}`; + } + } + return `settings/workspaces/${policyID}/accounting${queryParams}` as const; + }, }, WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/advanced', @@ -768,6 +803,26 @@ const ROUTES = { route: 'settings/workspaces/:policyID/categories/:categoryName/gl-code', getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/gl-code` as const, }, + WORSKPACE_CATEGORY_DEFAULT_TAX_RATE: { + route: 'settings/workspaces/:policyID/categories/:categoryName/tax-rate', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/tax-rate` as const, + }, + WORSKPACE_CATEGORY_FLAG_AMOUNTS_OVER: { + route: 'settings/workspaces/:policyID/categories/:categoryName/flag-amounts', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/flag-amounts` as const, + }, + WORSKPACE_CATEGORY_DESCRIPTION_HINT: { + route: 'settings/workspaces/:policyID/categories/:categoryName/description-hint', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/description-hint` as const, + }, + WORSKPACE_CATEGORY_REQUIRE_RECEIPTS_OVER: { + route: 'settings/workspaces/:policyID/categories/:categoryName/require-receipts-over', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/require-receipts-over` as const, + }, + WORSKPACE_CATEGORY_APPROVER: { + route: 'settings/workspaces/:policyID/categories/:categoryName/approver', + getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/categories/${encodeURIComponent(categoryName)}/approver` as const, + }, WORKSPACE_MORE_FEATURES: { route: 'settings/workspaces/:policyID/more-features', getRoute: (policyID: string) => `settings/workspaces/${policyID}/more-features` as const, @@ -898,6 +953,14 @@ const ROUTES = { route: 'settings/workspaces/:policyID/reportFields/:reportFieldID/edit/initialValue', getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportFields/${encodeURIComponent(reportFieldID)}/edit/initialValue` as const, }, + WORKSPACE_COMPANY_CARDS: { + route: 'settings/workspaces/:policyID/company-cards', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/company-cards` as const, + }, + WORKSPACE_COMPANY_CARDS_ADD_NEW: { + route: 'settings/workspaces/:policyID/company-cards/add-card-feed', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/company-cards/add-card-feed` as const, + }, WORKSPACE_COMPANY_CARDS_SELECT_FEED: { route: 'settings/workspaces/:policyID/company-cards/select-feed', getRoute: (policyID: string) => `settings/workspaces/${policyID}/company-cards/select-feed` as const, @@ -906,10 +969,6 @@ const ROUTES = { route: 'settings/workspaces/:policyID/expensify-card', getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card` as const, }, - WORKSPACE_COMPANY_CARDS: { - route: 'settings/workspaces/:policyID/company-cards', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/company-cards` as const, - }, WORKSPACE_COMPANY_CARDS_ASSIGN_CARD: { route: 'settings/workspaces/:policyID/company-cards/:feed/assign-card', getRoute: (policyID: string, feed: string) => `settings/workspaces/${policyID}/company-cards/${feed}/assign-card` as const, @@ -990,6 +1049,22 @@ const ROUTES = { route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-rate/edit', getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-rate/edit` as const, }, + RULES_CUSTOM_NAME: { + route: 'settings/workspaces/:policyID/rules/name', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/name` as const, + }, + RULES_AUTO_APPROVE_REPORTS_UNDER: { + route: 'settings/workspaces/:policyID/rules/auto-approve', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/auto-approve` as const, + }, + RULES_RANDOM_REPORT_AUDIT: { + route: 'settings/workspaces/:policyID/rules/audit', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/audit` as const, + }, + RULES_AUTO_PAY_REPORTS_UNDER: { + route: 'settings/workspaces/:policyID/rules/auto-pay', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/auto-pay` as const, + }, RULES_RECEIPT_REQUIRED_AMOUNT: { route: 'settings/workspaces/:policyID/rules/receipt-required-amount', getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/receipt-required-amount` as const, @@ -1390,7 +1465,9 @@ const ROUTES = { */ const HYBRID_APP_ROUTES = { MONEY_REQUEST_CREATE: '/request/new/scan', - MONEY_REQUEST_SUBMIT_CREATE: '/submit/new/scan', + MONEY_REQUEST_CREATE_TAB_SCAN: '/submit/new/scan', + MONEY_REQUEST_CREATE_TAB_MANUAL: '/submit/new/manual', + MONEY_REQUEST_CREATE_TAB_DISTANCE: '/submit/new/distance', } as const; export {HYBRID_APP_ROUTES, getUrlWithBackToParam, PUBLIC_SCREENS_ROUTES}; diff --git a/src/SCREENS.ts b/src/SCREENS.ts index db790dd389c3..b2039cc8701b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -47,7 +47,6 @@ const SCREENS = { ADVANCED_FILTERS_FROM_RHP: 'Search_Advanced_Filters_From_RHP', ADVANCED_FILTERS_TO_RHP: 'Search_Advanced_Filters_To_RHP', ADVANCED_FILTERS_IN_RHP: 'Search_Advanced_Filters_In_RHP', - ADVANCED_FILTERS_HAS_RHP: 'Search_Advanced_Filters_Has_RHP', TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', BOTTOM_TAB: 'Search_Bottom_Tab', }, @@ -129,6 +128,12 @@ const SCREENS = { CHANGE_PAYMENT_CURRENCY: 'Settings_Subscription_Change_Payment_Currency', REQUEST_EARLY_CANCELLATION: 'Settings_Subscription_RequestEarlyCancellation', }, + DELEGATE: { + ADD_DELEGATE: 'Settings_Delegate_Add', + DELEGATE_ROLE: 'Settings_Delegate_Role', + DELEGATE_CONFIRM: 'Settings_Delegate_Confirm', + DELEGATE_MAGIC_CODE: 'Settings_Delegate_Magic_Code', + }, }, SAVE_THE_WORLD: { ROOT: 'SaveTheWorld_Root', @@ -159,6 +164,7 @@ const SCREENS = { SIGN_IN: 'SignIn', PRIVATE_NOTES: 'Private_Notes', ROOM_MEMBERS: 'RoomMembers', + ROOM_MEMBER_DETAILS: 'RoomMembers_Details', ROOM_INVITE: 'RoomInvite', REFERRAL: 'Referral', PROCESS_MONEY_REQUEST_HOLD: 'ProcessMoneyRequestHold', @@ -370,6 +376,11 @@ const SCREENS = { COMPANY_CARDS: 'Workspace_CompanyCards', COMPANY_CARDS_ASSIGN_CARD: 'Workspace_CompanyCards_AssignCard', COMPANY_CARDS_SELECT_FEED: 'Workspace_CompanyCards_Select_Feed', + COMPANY_CARDS_ADD_NEW: 'Workspace_CompanyCards_New', + COMPANY_CARDS_TYPE: 'Workspace_CompanyCards_Type', + COMPANY_CARDS_INSTRUCTIONS: 'Workspace_CompanyCards_Instructions', + COMPANY_CARDS_NAME: 'Workspace_CompanyCards_Name', + COMPANY_CARDS_DETAILS: 'Workspace_CompanyCards_Details', COMPANY_CARDS_SETTINGS: 'Workspace_CompanyCards_Settings', COMPANY_CARDS_SETTINGS_FEED_NAME: 'Workspace_CompanyCards_Settings_Feed_Name', EXPENSIFY_CARD: 'Workspace_ExpensifyCard', @@ -435,6 +446,11 @@ const SCREENS = { CATEGORY_PAYROLL_CODE: 'Category_Payroll_Code', CATEGORY_GL_CODE: 'Category_GL_Code', CATEGORY_SETTINGS: 'Category_Settings', + CATEGORY_DEFAULT_TAX_RATE: 'Category_Default_Tax_Rate', + CATEGORY_FLAG_AMOUNTS_OVER: 'Category_Flag_Amounts_Over', + CATEGORY_DESCRIPTION_HINT: 'Category_Description_Hint', + CATEGORY_APPROVER: 'Category_Approver', + CATEGORY_REQUIRE_RECEIPTS_OVER: 'Category_Require_Receipts_Over', CATEGORIES_SETTINGS: 'Categories_Settings', CATEGORIES_IMPORT: 'Categories_Import', CATEGORIES_IMPORTED: 'Categories_Imported', @@ -452,6 +468,10 @@ const SCREENS = { DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit', UPGRADE: 'Workspace_Upgrade', RULES: 'Policy_Rules', + RULES_CUSTOM_NAME: 'Rules_Custom_Name', + RULES_AUTO_APPROVE_REPORTS_UNDER: 'Rules_Auto_Approve_Reports_Under', + RULES_RANDOM_REPORT_AUDIT: 'Rules_Random_Report_Audit', + RULES_AUTO_PAY_REPORTS_UNDER: 'Rules_AutoPay_Reports_Under', RULES_RECEIPT_REQUIRED_AMOUNT: 'Rules_Receipt_Required_Amount', RULES_MAX_EXPENSE_AMOUNT: 'Rules_Max_Expense_Amount', RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age', @@ -509,8 +529,11 @@ const SCREENS = { DETAILS: 'ReportParticipants_Details', ROLE: 'ReportParticipants_Role', }, - ROOM_MEMBERS_ROOT: 'RoomMembers_Root', - ROOM_INVITE_ROOT: 'RoomInvite_Root', + ROOM_MEMBERS: { + ROOT: 'RoomMembers_Root', + INVITE: 'RoomMembers_Invite', + DETAILS: 'RoomMember_Details', + }, FLAG_COMMENT_ROOT: 'FlagComment_Root', REIMBURSEMENT_ACCOUNT: 'ReimbursementAccount', GET_ASSISTANCE: 'GetAssistance', diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index ba30ea0062b9..a9e223e56632 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -1,3 +1,4 @@ +import {Str} from 'expensify-common'; import React, {useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -9,13 +10,14 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearDelegatorErrors, connect, disconnect} from '@libs/actions/Delegate'; +import * as ErrorUtils from '@libs/ErrorUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import variables from '@styles/variables'; import * as Modal from '@userActions/Modal'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails} from '@src/types/onyx'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; import Avatar from './Avatar'; import ConfirmModal from './ConfirmModal'; import Icon from './Icon'; @@ -45,16 +47,17 @@ function AccountSwitcher() { const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false; const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate); - const createBaseMenuItem = (personalDetails: PersonalDetails | undefined, error?: TranslationPaths, additionalProps: MenuItemWithLink = {}): MenuItemWithLink => { + const createBaseMenuItem = (personalDetails: PersonalDetails | undefined, errors?: Errors, additionalProps: MenuItemWithLink = {}): MenuItemWithLink => { + const error = Object.values(errors ?? {})[0] ?? ''; return { title: personalDetails?.displayName ?? personalDetails?.login, - description: personalDetails?.login, + description: Str.removeSMSDomain(personalDetails?.login ?? ''), avatarID: personalDetails?.accountID ?? -1, icon: personalDetails?.avatar ?? '', iconType: CONST.ICON_TYPE_AVATAR, outerWrapperStyle: shouldUseNarrowLayout ? {} : styles.accountSwitcherPopover, numberOfLinesDescription: 1, - errorText: error ? translate(error) : '', + errorText: error ?? '', shouldShowRedDotIndicator: !!error, errorTextStyle: styles.mt2, ...additionalProps, @@ -80,7 +83,7 @@ function AccountSwitcher() { } const delegatePersonalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); - const error = account?.delegatedAccess?.error; + const error = ErrorUtils.getLatestErrorField(account?.delegatedAccess, 'connect'); return [ createBaseMenuItem(delegatePersonalDetails, error, { @@ -99,7 +102,8 @@ function AccountSwitcher() { const delegatorMenuItems: MenuItemProps[] = delegators .filter(({email}) => email !== currentUserPersonalDetails.login) - .map(({email, role, error}, index) => { + .map(({email, role, errorFields}, index) => { + const error = ErrorUtils.getLatestErrorField({errorFields}, 'connect'); const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email); return createBaseMenuItem(personalDetails, error, { badgeText: translate('delegate.role', role), @@ -132,7 +136,7 @@ function AccountSwitcher() { <View style={[styles.flexRow, styles.gap3]}> <Avatar type={CONST.ICON_TYPE_AVATAR} - size={CONST.AVATAR_SIZE.MEDIUM} + size={CONST.AVATAR_SIZE.DEFAULT} avatarID={currentUserPersonalDetails?.accountID} source={currentUserPersonalDetails?.avatar} fallbackIcon={currentUserPersonalDetails.fallbackIcon} @@ -160,7 +164,7 @@ function AccountSwitcher() { numberOfLines={1} style={[styles.colorMuted, styles.fontSizeLabel]} > - {currentUserPersonalDetails?.login} + {Str.removeSMSDomain(currentUserPersonalDetails?.login ?? '')} </Text> </View> </View> @@ -176,7 +180,7 @@ function AccountSwitcher() { anchorPosition={styles.accountSwitcherAnchorPosition} > <View style={styles.pb4}> - <Text style={[styles.createMenuHeaderText, styles.ph5, styles.pb2, styles.pt4]}>{translate('delegate.switchAccount')}</Text> + <Text style={[styles.createMenuHeaderText, styles.ph5, styles.pb3, !shouldUseNarrowLayout && styles.pt4]}>{translate('delegate.switchAccount')}</Text> <MenuItemList menuItems={menuItems()} shouldUseSingleExecution diff --git a/src/components/AccountSwitcherSkeletonView/index.tsx b/src/components/AccountSwitcherSkeletonView/index.tsx index eb01a23e9ade..3faf7e563f3c 100644 --- a/src/components/AccountSwitcherSkeletonView/index.tsx +++ b/src/components/AccountSwitcherSkeletonView/index.tsx @@ -16,7 +16,7 @@ type AccountSwitcherSkeletonViewProps = { avatarSize?: ValueOf<typeof CONST.AVATAR_SIZE>; }; -function AccountSwitcherSkeletonView({shouldAnimate = true, avatarSize = CONST.AVATAR_SIZE.LARGE}: AccountSwitcherSkeletonViewProps) { +function AccountSwitcherSkeletonView({shouldAnimate = true, avatarSize = CONST.AVATAR_SIZE.DEFAULT}: AccountSwitcherSkeletonViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -30,7 +30,7 @@ function AccountSwitcherSkeletonView({shouldAnimate = true, avatarSize = CONST.A animate={shouldAnimate} backgroundColor={theme.skeletonLHNIn} foregroundColor={theme.skeletonLHNOut} - height={avatarPlaceholderSize + styles.pb3.paddingBottom} + height={avatarPlaceholderSize} > <Circle cx={startPositionX} @@ -39,13 +39,13 @@ function AccountSwitcherSkeletonView({shouldAnimate = true, avatarSize = CONST.A /> <Rect x={startPositionX + avatarPlaceholderRadius + styles.gap3.gap} - y="11" + y="6" width="45%" height="8" /> <Rect x={startPositionX + avatarPlaceholderRadius + styles.gap3.gap} - y="31" + y="26" width="55%" height="8" /> diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 7ca4cc3273ca..bd032df9a244 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -4,8 +4,8 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; -import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; +import CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/HomeAddressForm'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -14,6 +14,7 @@ import CountrySelector from './CountrySelector'; import FormProvider from './Form/FormProvider'; import InputWrapper from './Form/InputWrapper'; import type {FormOnyxValues} from './Form/types'; +import type {State} from './StateSelector'; import StateSelector from './StateSelector'; import TextInput from './TextInput'; @@ -192,7 +193,7 @@ function AddressForm({ <InputWrapper InputComponent={StateSelector} inputID={INPUT_IDS.STATE} - defaultValue={state} + value={state as State} onValueChange={onAddressChanged} shouldSaveDraft={shouldSaveDraft} /> diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index 8ad01d4437ae..09b5fd0cf7d6 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -43,8 +43,10 @@ type AmountFormProps = { /** Custom max amount length. It defaults to CONST.IOU.AMOUNT_MAX_LENGTH */ amountMaxLength?: number; + /** Custom label for the TextInput */ label?: string; + /** Whether the form should use a standard TextInput as a base */ displayAsTextInput?: boolean; } & Pick<TextInputWithCurrencySymbolProps, 'hideCurrencySymbol' | 'extraSymbol'> & Pick<BaseTextInputProps, 'autoFocus'>; diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx index 1d273e847d26..1f1844bf20db 100644 --- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx +++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx @@ -41,7 +41,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow return ( <ShowContextMenuContext.Consumer> - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( <PressableWithoutFeedback style={[style, isOffline && styles.cursorDefault]} onPress={() => { @@ -53,9 +53,12 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow }} onPressIn={onPressIn} onPressOut={onPressOut} - onLongPress={(event) => - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)) - } + onLongPress={(event) => { + if (isDisabled) { + return; + } + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); + }} shouldUseHapticsOnLongPress accessibilityLabel={displayName} role={CONST.ROLE.BUTTON} diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx index 859d59278cdd..af77a20b4caa 100644 --- a/src/components/ArchivedReportFooter.tsx +++ b/src/components/ArchivedReportFooter.tsx @@ -47,6 +47,10 @@ function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}} let policyName = ReportUtils.getPolicyName(report); + if (archiveReason === CONST.REPORT.ARCHIVE_REASON.INVOICE_RECEIVER_POLICY_DELETED) { + policyName = originalMessage?.receiverPolicyName ?? ''; + } + if (shouldRenderHTML) { oldDisplayName = lodashEscape(oldDisplayName); displayName = lodashEscape(displayName); diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 14b7ac6f2313..c327d7fa6093 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -246,13 +246,14 @@ function AttachmentModal({ } if (typeof sourceURL === 'string') { - fileDownload(sourceURL, file?.name ?? ''); + const fileName = type === CONST.ATTACHMENT_TYPE.SEARCH ? FileUtils.getFileName(`${sourceURL}`) : file?.name; + fileDownload(sourceURL, fileName ?? ''); } // At ios, if the keyboard is open while opening the attachment, then after downloading // the attachment keyboard will show up. So, to fix it we need to dismiss the keyboard. Keyboard.dismiss(); - }, [isAuthTokenRequiredState, sourceState, file]); + }, [isAuthTokenRequiredState, sourceState, file, type]); /** * Execute the onConfirm callback and close the modal. @@ -460,7 +461,7 @@ function AttachmentModal({ let headerTitleNew = headerTitle; let shouldShowDownloadButton = false; let shouldShowThreeDotsButton = false; - if (!isEmptyObject(report)) { + if (!isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH) { headerTitleNew = translate(isReceiptAttachment ? 'common.receipt' : 'common.attachment'); shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isReceiptAttachment && !isOffline && !isLocalSource; shouldShowThreeDotsButton = isReceiptAttachment && isModalOpen && threeDotsMenuItems.length !== 0; diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index e9406db118e7..6d14a41741a6 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -153,7 +153,7 @@ function AttachmentView({ ); } - if (TransactionUtils.hasEReceipt(transaction) && transaction) { + if (transaction && !TransactionUtils.hasReceiptSource(transaction) && TransactionUtils.hasEReceipt(transaction)) { return ( <View style={[styles.flex1, styles.alignItemsCenter]}> <ScrollView diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 2ccdd47c3205..1cd1bfb36d83 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -12,7 +12,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; +import type {PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; import CaretWrapper from './CaretWrapper'; import DisplayNames from './DisplayNames'; import MultipleAvatars from './MultipleAvatars'; @@ -69,7 +69,7 @@ function AvatarWithDisplayName({ ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report) || ReportUtils.isInvoiceReport(report); const icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails) as PersonalDetails[], false); + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false); const shouldShowSubscriptAvatar = ReportUtils.shouldReportShowSubscript(report); const avatarBorderColor = isAnonymous ? theme.highlightBG : theme.componentBG; diff --git a/src/components/BulletList.tsx b/src/components/BulletList.tsx new file mode 100644 index 000000000000..8aee1aa5076f --- /dev/null +++ b/src/components/BulletList.tsx @@ -0,0 +1,52 @@ +import type {ReactNode} from 'react'; +import React from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Text from './Text'; + +type BulletListItem = string; + +type BulletListProps = { + /** List of items for the list. Each item will be rendered as a sepearte point. */ + items: BulletListItem[]; + + /** Header section of the list */ + header: string | ReactNode; +}; + +function BulletList({items, header}: BulletListProps) { + const styles = useThemeStyles(); + + const baseTextStyles = [styles.mutedNormalTextLabel]; + + const renderBulletListHeader = () => { + if (typeof header === 'string') { + return <Text style={baseTextStyles}>{header}</Text>; + } + return header; + }; + + const renderBulletPoint = (item: string) => { + return ( + <Text + style={baseTextStyles} + key={item} + > + <Text style={[styles.ph2, baseTextStyles]}>β’</Text> + {item} + </Text> + ); + }; + + return ( + <View style={[styles.w100, styles.mt2]}> + {renderBulletListHeader()} + <View>{items.map((item) => renderBulletPoint(item))}</View> + </View> + ); +} + +BulletList.displayName = 'BulletList'; + +export type {BulletListProps}; +export default BulletList; diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 0abc55088647..ddab08bdc1d3 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -66,7 +66,7 @@ function ButtonWithDropdownMenu<IValueType>({ if ('measureInWindow' in dropdownAnchor.current) { dropdownAnchor.current.measureInWindow((x, y, w, h) => { setPopoverAnchorPosition({ - horizontal: x + w + h, + horizontal: x + w, vertical: anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP ? y + h + CONST.MODAL.POPOVER_MENU_PADDING // if vertical anchorAlignment is TOP, menu will open below the button and we need to add the height of button and padding diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 82b26a39e1db..58689958fb53 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -1,16 +1,17 @@ import type {RefObject} from 'react'; import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; -import type {PaymentMethodType} from '@components/KYCWall/types'; import type CONST from '@src/CONST'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; -type PaymentType = DeepValueOf<typeof CONST.IOU.PAYMENT_TYPE | typeof CONST.IOU.REPORT_ACTION_TYPE | PaymentMethodType>; +type PaymentType = DeepValueOf<typeof CONST.IOU.PAYMENT_TYPE | typeof CONST.IOU.REPORT_ACTION_TYPE>; type WorkspaceMemberBulkActionType = DeepValueOf<typeof CONST.POLICY.MEMBERS_BULK_ACTION_TYPES>; +type RoomMemberBulkActionType = DeepValueOf<typeof CONST.REPORT.ROOM_MEMBERS_BULK_ACTION_TYPES>; + type WorkspaceDistanceRatesBulkActionType = DeepValueOf<typeof CONST.POLICY.BULK_ACTION_TYPES>; type WorkspaceTaxRatesBulkActionType = DeepValueOf<typeof CONST.POLICY.BULK_ACTION_TYPES>; @@ -105,6 +106,7 @@ type ButtonWithDropdownMenuProps<TValueType> = { export type { PaymentType, WorkspaceMemberBulkActionType, + RoomMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps, diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index e9558297e577..9d6bd3a0a76a 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -19,6 +19,9 @@ type ConfirmModalProps = { /** A callback to call when the form has been closed */ onCancel?: () => void; + /** A callback to call when backdrop is pressed */ + onBackdropPress?: () => void; + /** Modal visibility */ isVisible: boolean; @@ -108,6 +111,7 @@ function ConfirmModal({ success = true, danger = false, onCancel = () => {}, + onBackdropPress, shouldDisableConfirmButtonWhenOffline = false, shouldShowCancelButton = true, shouldSetModalVisibility = true, @@ -140,6 +144,7 @@ function ConfirmModal({ <Modal onSubmit={onConfirm} onClose={onCancel} + onBackdropPress={onBackdropPress} isVisible={isVisible} shouldSetModalVisibility={shouldSetModalVisibility} onModalHide={onModalHide} diff --git a/src/components/DelegateNoAccessModal.tsx b/src/components/DelegateNoAccessModal.tsx new file mode 100644 index 000000000000..8b708459c122 --- /dev/null +++ b/src/components/DelegateNoAccessModal.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import useLocalize from '@hooks/useLocalize'; +import CONST from '@src/CONST'; +import ConfirmModal from './ConfirmModal'; +import Text from './Text'; +import TextLink from './TextLink'; + +type DelegateNoAccessModalProps = { + isNoDelegateAccessMenuVisible: boolean; + onClose: () => void; + delegatorEmail: string; +}; + +export default function DelegateNoAccessModal({isNoDelegateAccessMenuVisible = false, onClose, delegatorEmail = ''}: DelegateNoAccessModalProps) { + const {translate} = useLocalize(); + const noDelegateAccessPromptStart = translate('delegate.notAllowedMessageStart', {accountOwnerEmail: delegatorEmail}); + const noDelegateAccessHyperLinked = translate('delegate.notAllowedMessageHyperLinked'); + const noDelegateAccessPromptEnd = translate('delegate.notAllowedMessageEnd'); + + const delegateNoAccessPrompt = ( + <Text> + {noDelegateAccessPromptStart} + <TextLink href={CONST.DELEGATE_ROLE_HELPDOT_ARTICLE_LINK}>{noDelegateAccessHyperLinked}</TextLink> + {noDelegateAccessPromptEnd} + </Text> + ); + + return ( + <ConfirmModal + isVisible={isNoDelegateAccessMenuVisible} + onConfirm={onClose} + onCancel={onClose} + title={translate('delegate.notAllowed')} + prompt={delegateNoAccessPrompt} + confirmText={translate('common.buttonConfirm')} + shouldShowCancelButton={false} + /> + ); +} diff --git a/src/components/EReceipt.tsx b/src/components/EReceipt.tsx index bfb59dc748ab..026713027f96 100644 --- a/src/components/EReceipt.tsx +++ b/src/components/EReceipt.tsx @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -44,7 +45,7 @@ function EReceipt({transaction, transactionID}: EReceiptProps) { const formattedAmount = CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency); const currency = CurrencyUtils.getCurrencySymbol(transactionCurrency ?? ''); const amount = currency ? formattedAmount.replace(currency, '') : formattedAmount; - const cardDescription = transactionCardID ? CardUtils.getCardDescription(transactionCardID) : ''; + const cardDescription = TransactionUtils.getCardName(transaction) ?? (transactionCardID ? CardUtils.getCardDescription(transactionCardID) : ''); const secondaryTextColorStyle = secondaryColor ? StyleUtils.getColorStyle(secondaryColor) : undefined; diff --git a/src/components/ExplanationModal.tsx b/src/components/ExplanationModal.tsx index c6294f600993..73290c43d39a 100644 --- a/src/components/ExplanationModal.tsx +++ b/src/components/ExplanationModal.tsx @@ -3,8 +3,8 @@ import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import FeatureTrainingModal from './FeatureTrainingModal'; function ExplanationModal() { @@ -18,7 +18,7 @@ function ExplanationModal() { onNotCompleted: () => { setTimeout(() => { Navigation.isNavigationReady().then(() => { - Navigation.navigate(ROUTES.ONBOARDING_ROOT.route); + OnboardingFlow.startOnboardingFlow(); }); }, variables.welcomeVideoDelay); }, diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 5f56bbeceea6..88ccc31c0979 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -11,6 +11,7 @@ import type CountrySelector from '@components/CountrySelector'; import type CurrencySelector from '@components/CurrencySelector'; import type DatePicker from '@components/DatePicker'; import type EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown'; +import type PercentageForm from '@components/PercentageForm'; import type Picker from '@components/Picker'; import type RadioButtons from '@components/RadioButtons'; import type RoomNameInput from '@components/RoomNameInput'; @@ -42,6 +43,7 @@ type ValidInputs = | typeof CountrySelector | typeof CurrencySelector | typeof AmountForm + | typeof PercentageForm | typeof BusinessTypePicker | typeof DimensionTypeSelector | typeof StateSelector diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 771d2631379e..bbf7dfcb0c6a 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -80,7 +80,7 @@ function ImageRenderer({tnode}: ImageRendererProps) { thumbnailImageComponent ) : ( <ShowContextMenuContext.Consumer> - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( <AttachmentContext.Consumer> {({reportID, accountID, type}) => ( <PressableWithoutFocus @@ -90,14 +90,15 @@ function ImageRenderer({tnode}: ImageRendererProps) { return; } - if (reportID) { - const route = ROUTES.ATTACHMENTS?.getRoute(reportID, type, source, accountID); - Navigation.navigate(route); + const route = ROUTES.ATTACHMENTS?.getRoute(reportID ?? '-1', type, source, accountID); + Navigation.navigate(route); + }} + onLongPress={(event) => { + if (isDisabled) { + return; } + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); }} - onLongPress={(event) => - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)) - } shouldUseHapticsOnLongPress accessibilityRole={CONST.ROLE.BUTTON} accessibilityLabel={translate('accessibilityHints.viewAttachment')} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index ffab2434c83c..6acef20cd833 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -83,12 +83,15 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona return ( <ShowContextMenuContext.Consumer> - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( <Text suppressHighlighting - onLongPress={(event) => - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)) - } + onLongPress={(event) => { + if (isDisabled) { + return; + } + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); + }} onPress={(event) => { event.preventDefault(); Navigation.navigate(navigationRoute); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index 14666798e8c7..b1e5c21500f0 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -34,14 +34,17 @@ function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...d return ( <View style={isLast ? styles.mt2 : styles.mv2}> <ShowContextMenuContext.Consumer> - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( <PressableWithoutFeedback onPress={onPressIn ?? (() => {})} onPressIn={onPressIn} onPressOut={onPressOut} - onLongPress={(event) => - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)) - } + onLongPress={(event) => { + if (isDisabled) { + return; + } + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); + }} shouldUseHapticsOnLongPress role={CONST.ROLE.PRESENTATION} accessibilityLabel={translate('accessibilityHints.prestyledText')} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx index e0df7e7081c5..ce822af14cb8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type {CustomRendererProps, TBlock} from 'react-native-render-html'; +import {AttachmentContext} from '@components/AttachmentContext'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import VideoPlayerPreview from '@components/VideoPlayerPreview'; import useCurrentReportID from '@hooks/useCurrentReportID'; @@ -28,19 +29,26 @@ function VideoRenderer({tnode, key}: VideoRendererProps) { return ( <ShowContextMenuContext.Consumer> {({report}) => ( - <VideoPlayerPreview - key={key} - videoUrl={sourceURL} - reportID={currentReportIDValue?.currentReportID ?? '-1'} - fileName={fileName} - thumbnailUrl={thumbnailUrl} - videoDimensions={{width, height}} - videoDuration={duration} - onShowModalPress={() => { - const route = ROUTES.ATTACHMENTS.getRoute(report?.reportID ?? '-1', CONST.ATTACHMENT_TYPE.REPORT, sourceURL); - Navigation.navigate(route); - }} - /> + <AttachmentContext.Consumer> + {({accountID, type}) => ( + <VideoPlayerPreview + key={key} + videoUrl={sourceURL} + reportID={currentReportIDValue?.currentReportID ?? '-1'} + fileName={fileName} + thumbnailUrl={thumbnailUrl} + videoDimensions={{width, height}} + videoDuration={duration} + onShowModalPress={() => { + if (!sourceURL || !type) { + return; + } + const route = ROUTES.ATTACHMENTS.getRoute(report?.reportID ?? '-1', type, sourceURL, accountID); + Navigation.navigate(route); + }} + /> + )} + </AttachmentContext.Consumer> )} </ShowContextMenuContext.Consumer> ); diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 8c492f84a383..698dc33b4a03 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -170,6 +170,7 @@ import Podcast from '@assets/images/social-podcast.svg'; import Twitter from '@assets/images/social-twitter.svg'; import Youtube from '@assets/images/social-youtube.svg'; import SpreadsheetComputer from '@assets/images/spreadsheet-computer.svg'; +import Star from '@assets/images/Star.svg'; import Stopwatch from '@assets/images/stopwatch.svg'; import Suitcase from '@assets/images/suitcase.svg'; import Sync from '@assets/images/sync.svg'; @@ -185,6 +186,7 @@ import Unlock from '@assets/images/unlock.svg'; import UploadAlt from '@assets/images/upload-alt.svg'; import Upload from '@assets/images/upload.svg'; import UserCheck from '@assets/images/user-check.svg'; +import UserPlus from '@assets/images/user-plus.svg'; import User from '@assets/images/user.svg'; import Users from '@assets/images/users.svg'; import VolumeHigh from '@assets/images/volume-high.svg'; @@ -391,7 +393,9 @@ export { CalendarSolid, Filter, CaretUpDown, + UserPlus, Feed, Table, SpreadsheetComputer, + Star, }; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 0616794a8e3a..446137c3749b 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -1,4 +1,5 @@ import AmexCompanyCards from '@assets/images/companyCards/amex.svg'; +import AmexBlueCompanyCards from '@assets/images/companyCards/card-amex-blue.svg'; import CompanyCardsEmptyState from '@assets/images/companyCards/emptystate__card-pos.svg'; import MasterCardCompanyCards from '@assets/images/companyCards/mastercard.svg'; import VisaCompanyCards from '@assets/images/companyCards/visa.svg'; @@ -56,6 +57,7 @@ import CheckmarkCircle from '@assets/images/simple-illustrations/simple-illustra import CoffeeMug from '@assets/images/simple-illustrations/simple-illustration__coffeemug.svg'; import Coins from '@assets/images/simple-illustrations/simple-illustration__coins.svg'; import CommentBubbles from '@assets/images/simple-illustrations/simple-illustration__commentbubbles.svg'; +import CommentBubblesBlue from '@assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg'; import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustration__concierge-bubble.svg'; import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg'; import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; @@ -182,6 +184,7 @@ export { SmartScan, Hourglass, CommentBubbles, + CommentBubblesBlue, TrashCan, TeleScope, Profile, @@ -233,5 +236,6 @@ export { AmexCompanyCards, MasterCardCompanyCards, VisaCompanyCards, + AmexBlueCompanyCards, TurtleInShell, }; diff --git a/src/components/ImportColumn.tsx b/src/components/ImportColumn.tsx index f25082f94474..49601787a207 100644 --- a/src/components/ImportColumn.tsx +++ b/src/components/ImportColumn.tsx @@ -156,7 +156,7 @@ function ImportColumn({column, columnName, columnRoles, columnIndex}: ImportColu text: item.text, value: item.value, description: item.description ?? (item.isRequired ? translate('common.required') : undefined), - isSelected: spreadsheet?.columns[columnIndex] === item.value, + isSelected: spreadsheet?.columns?.[columnIndex] === item.value, })); const columnValuesString = column.slice(containsHeader ? 1 : 0).join(', '); @@ -176,12 +176,17 @@ function ImportColumn({column, columnName, columnRoles, columnIndex}: ImportColu return ( <View style={[styles.importColumnCard, styles.mt4]}> - <Text style={[styles.textSupporting, styles.mw100]}>{columnHeader}</Text> + <Text + numberOfLines={1} + style={[styles.textSupporting, styles.mw100]} + > + {columnHeader} + </Text> <View style={[styles.flexRow, styles.alignItemsCenter, styles.mt2]}> <Text numberOfLines={2} ellipsizeMode="tail" - style={[styles.flex1, styles.flexWrap]} + style={[styles.flex1, styles.flexWrap, styles.breakAll]} > {columnValuesString} </Text> diff --git a/src/components/ImportSpreadsheet.tsx b/src/components/ImportSpreadsheet.tsx index cdfc5bd05a36..5bd3f473223f 100644 --- a/src/components/ImportSpreadsheet.tsx +++ b/src/components/ImportSpreadsheet.tsx @@ -65,7 +65,7 @@ function ImportSpreedsheet({backTo, goTo}: ImportSpreedsheetProps) { } if ((file?.size ?? 0) <= 0) { - setUploadFileError(true, 'attachmentPicker.attachmentTooSmall', 'attachmentPicker.sizeNotMet'); + setUploadFileError(true, 'attachmentPicker.attachmentTooSmall', 'spreadsheet.sizeNotMet'); return false; } return true; @@ -75,15 +75,15 @@ function ImportSpreedsheet({backTo, goTo}: ImportSpreedsheetProps) { if (!validateFile(file)) { return; } - if (!file.uri) { + let fileURI = file.uri ?? URL.createObjectURL(file); + if (!fileURI) { return; } - let filePath = file.uri; if (Platform.OS === 'ios') { - filePath = filePath.replace(/^.*\/Documents\//, `${RNFetchBlob.fs.dirs.DocumentDir}/`); + fileURI = fileURI.replace(/^.*\/Documents\//, `${RNFetchBlob.fs.dirs.DocumentDir}/`); } - fetch(filePath) + fetch(fileURI) .then((data) => { setIsReadingFIle(true); return data.arrayBuffer(); @@ -102,6 +102,9 @@ function ImportSpreedsheet({backTo, goTo}: ImportSpreedsheetProps) { }) .finally(() => { setIsReadingFIle(false); + if (fileURI && !file.uri) { + URL.revokeObjectURL(fileURI); + } }); }; @@ -164,7 +167,7 @@ function ImportSpreedsheet({backTo, goTo}: ImportSpreedsheetProps) { <DragAndDropProvider setIsDraggingOver={setIsDraggingOver}> <View style={[styles.flex1, safeAreaPaddingBottomStyle]}> <HeaderWithBackButton - title={translate('common.importSpreadsheet')} + title={translate('spreadsheet.importSpreadsheet')} onBackButtonPress={() => Navigation.navigate(backTo)} /> @@ -189,7 +192,7 @@ function ImportSpreedsheet({backTo, goTo}: ImportSpreedsheetProps) { height={CONST.IMPORT_SPREADSHEET.ICON_HEIGHT} /> <Text style={[styles.textFileUpload]}>{translate('common.dropTitle')}</Text> - <Text style={[styles.subTextFileUpload, styles.textSupporting]}>{translate('common.dropMessage')}</Text> + <Text style={[styles.subTextFileUpload, styles.themeTextColor]}>{translate('common.dropMessage')}</Text> </View> </View> </DragAndDropConsumer> diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 625973bbbe59..fd681546c470 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -1,12 +1,16 @@ -import React, {useCallback, useRef} from 'react'; -import type {GestureResponderEvent, View} from 'react-native'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {Dimensions} from 'react-native'; +import type {EmitterSubscription, GestureResponderEvent, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu'; import * as BankAccounts from '@libs/actions/BankAccounts'; +import getClickedTargetLocation from '@libs/getClickedTargetLocation'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import * as PaymentUtils from '@libs/PaymentUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as PaymentMethods from '@userActions/PaymentMethods'; import * as Policy from '@userActions/Policy/Policy'; import * as Wallet from '@userActions/Wallet'; import CONST from '@src/CONST'; @@ -15,7 +19,10 @@ import ROUTES from '@src/ROUTES'; import type {BankAccountList, FundList, ReimbursementAccount, UserWallet, WalletTerms} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import viewRef from '@src/types/utils/viewRef'; -import type {KYCWallProps, PaymentMethod} from './types'; +import type {AnchorPosition, DomRect, KYCWallProps, PaymentMethod} from './types'; + +// This sets the Horizontal anchor position offset for POPOVER MENU. +const POPOVER_MENU_ANCHOR_POSITION_HORIZONTAL_OFFSET = 20; type BaseKYCWallOnyxProps = { /** The user's wallet */ @@ -42,6 +49,10 @@ type BaseKYCWallProps = KYCWallProps & BaseKYCWallOnyxProps; function KYCWall({ addBankAccountRoute, addDebitCardRoute, + anchorAlignment = { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }, bankAccountList = {}, chatReportID = '', children, @@ -52,13 +63,60 @@ function KYCWall({ onSuccessfulKYC, reimbursementAccount, shouldIncludeDebitCard = true, + shouldListenForResize = false, source, userWallet, walletTerms, + shouldShowPersonalBankAccountOption = false, }: BaseKYCWallProps) { const anchorRef = useRef<HTMLDivElement | View>(null); const transferBalanceButtonRef = useRef<HTMLDivElement | View | null>(null); + const [shouldShowAddPaymentMenu, setShouldShowAddPaymentMenu] = useState(false); + + const [anchorPosition, setAnchorPosition] = useState({ + anchorPositionVertical: 0, + anchorPositionHorizontal: 0, + }); + + const getAnchorPosition = useCallback( + (domRect: DomRect): AnchorPosition => { + if (anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) { + return { + anchorPositionVertical: domRect.top + domRect.height + CONST.MODAL.POPOVER_MENU_PADDING, + anchorPositionHorizontal: domRect.left + POPOVER_MENU_ANCHOR_POSITION_HORIZONTAL_OFFSET, + }; + } + + return { + anchorPositionVertical: domRect.top - CONST.MODAL.POPOVER_MENU_PADDING, + anchorPositionHorizontal: domRect.left, + }; + }, + [anchorAlignment.vertical], + ); + + /** + * Set position of the transfer payment menu + */ + const setPositionAddPaymentMenu = ({anchorPositionVertical, anchorPositionHorizontal}: AnchorPosition) => { + setAnchorPosition({ + anchorPositionVertical, + anchorPositionHorizontal, + }); + }; + + const setMenuPosition = useCallback(() => { + if (!transferBalanceButtonRef.current) { + return; + } + + const buttonPosition = getClickedTargetLocation(transferBalanceButtonRef.current as HTMLDivElement); + const position = getAnchorPosition(buttonPosition); + + setPositionAddPaymentMenu(position); + }, [getAnchorPosition]); + const selectPaymentMethod = useCallback( (paymentMethod: PaymentMethod) => { onSelectPaymentMethod(paymentMethod); @@ -101,6 +159,11 @@ function KYCWall({ */ Wallet.setKYCWallSource(source, chatReportID); + if (shouldShowAddPaymentMenu) { + setShouldShowAddPaymentMenu(false); + return; + } + // Use event target as fallback if anchorRef is null for safety const targetElement = anchorRef.current ?? (event?.currentTarget as HTMLDivElement); @@ -121,19 +184,11 @@ function KYCWall({ return; } - switch (iouPaymentType) { - case CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT: - selectPaymentMethod(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); - break; - case CONST.PAYMENT_METHODS.DEBIT_CARD: - selectPaymentMethod(CONST.PAYMENT_METHODS.DEBIT_CARD); - break; - case CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT: - selectPaymentMethod(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT); - break; - default: - break; - } + const clickedElementLocation = getClickedTargetLocation(targetElement as HTMLDivElement); + const position = getAnchorPosition(clickedElementLocation); + + setPositionAddPaymentMenu(position); + setShouldShowAddPaymentMenu(true); return; } @@ -159,18 +214,58 @@ function KYCWall({ chatReportID, enablePaymentsRoute, fundList, + getAnchorPosition, iouReport, onSuccessfulKYC, reimbursementAccount?.achData?.state, selectPaymentMethod, shouldIncludeDebitCard, + shouldShowAddPaymentMenu, source, userWallet?.tierName, walletTerms?.source, ], ); - return <>{children(continueAction, viewRef(anchorRef))}</>; + useEffect(() => { + let dimensionsSubscription: EmitterSubscription | null = null; + + PaymentMethods.kycWallRef.current = {continueAction}; + + if (shouldListenForResize) { + dimensionsSubscription = Dimensions.addEventListener('change', setMenuPosition); + } + + return () => { + if (shouldListenForResize && dimensionsSubscription) { + dimensionsSubscription.remove(); + } + + PaymentMethods.kycWallRef.current = null; + }; + }, [chatReportID, setMenuPosition, shouldListenForResize, continueAction]); + + return ( + <> + <AddPaymentMethodMenu + isVisible={shouldShowAddPaymentMenu} + iouReport={iouReport} + onClose={() => setShouldShowAddPaymentMenu(false)} + anchorRef={anchorRef} + anchorPosition={{ + vertical: anchorPosition.anchorPositionVertical, + horizontal: anchorPosition.anchorPositionHorizontal, + }} + anchorAlignment={anchorAlignment} + onItemSelected={(item: PaymentMethod) => { + setShouldShowAddPaymentMenu(false); + selectPaymentMethod(item); + }} + shouldShowPersonalBankAccountOption={shouldShowPersonalBankAccountOption} + /> + {children(continueAction, viewRef(anchorRef))} + </> + ); } KYCWall.displayName = 'BaseKYCWall'; diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index a734890a1f38..624e8f18e69e 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -22,7 +22,9 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails} from '@src/types/onyx'; import OptionRowLHNData from './OptionRowLHNData'; +import OptionRowRendererComponent from './OptionRowRendererComponent'; import type {LHNOptionsListProps, RenderItemProps} from './types'; const keyExtractor = (item: string) => `report_${item}`; @@ -148,6 +150,20 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio } const lastReportActionTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${lastReportActionTransactionID}`]; + // SidebarUtils.getOptionData in OptionRowLHNData does not get re-evaluated when the linked task report changes, so we have the lastMessageTextFromReport evaluation logic here + let lastActorDetails: Partial<PersonalDetails> | null = + itemFullReport?.lastActorAccountID && personalDetails?.[itemFullReport.lastActorAccountID] ? personalDetails[itemFullReport.lastActorAccountID] : null; + if (!lastActorDetails && lastReportAction) { + const lastActorDisplayName = lastReportAction?.person?.[0]?.text; + lastActorDetails = lastActorDisplayName + ? { + displayName: lastActorDisplayName, + accountID: itemFullReport?.lastActorAccountID, + } + : null; + } + const lastMessageTextFromReport = OptionsListUtils.getLastMessageTextForReport(itemFullReport, lastActorDetails, itemPolicy); + return ( <OptionRowLHNData reportID={reportID} @@ -163,6 +179,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio receiptTransactions={transactions} viewMode={optionMode} isFocused={!shouldDisableFocusOptions} + lastMessageTextFromReport={lastMessageTextFromReport} onSelectRow={onSelectRow} preferredLocale={preferredLocale} hasDraftComment={hasDraftComment} @@ -251,6 +268,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio ref={flashListRef} indicatorStyle="white" keyboardShouldPersistTaps="always" + CellRendererComponent={OptionRowRendererComponent} contentContainerStyle={StyleSheet.flatten(contentContainerStyles)} data={data} testID="lhn-options-list" diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index cee949133eb2..1686ed1c62d2 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -14,6 +14,7 @@ import PressableWithSecondaryInteraction from '@components/PressableWithSecondar import SubscriptAvatar from '@components/SubscriptAvatar'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; +import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -21,6 +22,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import DomUtils from '@libs/DomUtils'; +import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import Performance from '@libs/Performance'; @@ -28,6 +30,8 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import * as ReportUtils from '@libs/ReportUtils'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import variables from '@styles/variables'; +import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -38,24 +42,53 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti const styles = useThemeStyles(); const popoverAnchor = useRef<View>(null); const StyleUtils = useStyleUtils(); - const isFocusedRef = useRef(true); + const [isScreenFocused, setIsScreenFocused] = useState(false); const {shouldUseNarrowLayout} = useResponsiveLayout(); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${optionItem?.reportID || -1}`); + const [isFirstTimeNewExpensifyUser] = useOnyx(ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER); + const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { + selector: hasCompletedGuidedSetupFlowSelector, + }); + const [shouldHideGBRTooltip] = useOnyx(ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP, {initialValue: true}); const {translate} = useLocalize(); const [isContextMenuActive, setIsContextMenuActive] = useState(false); useFocusEffect( useCallback(() => { - isFocusedRef.current = true; + setIsScreenFocused(true); return () => { - isFocusedRef.current = false; + setIsScreenFocused(false); }; }, []), ); + const renderGBRTooltip = useCallback( + () => ( + <View style={[styles.alignItemsCenter, styles.flexRow, styles.justifyContentCenter, styles.flexWrap, styles.textAlignCenter, styles.gap1]}> + <Icon + src={Expensicons.Lightbulb} + fill={theme.tooltipHighlightText} + medium + /> + <Text style={styles.quickActionTooltipSubtitle}>{translate('sidebarScreen.tooltip')}</Text> + </View> + ), + [ + styles.alignItemsCenter, + styles.flexRow, + styles.justifyContentCenter, + styles.flexWrap, + styles.textAlignCenter, + styles.gap1, + styles.quickActionTooltipSubtitle, + theme.tooltipHighlightText, + translate, + ], + ); + const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; const sidebarInnerRowStyle = StyleSheet.flatten<ViewStyle>( isInFocusMode @@ -100,7 +133,7 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti * @param [event] - A press event. */ const showPopover = (event: MouseEvent | GestureResponderEvent) => { - if (!isFocusedRef.current && shouldUseNarrowLayout) { + if (!isScreenFocused && shouldUseNarrowLayout) { return; } setIsContextMenuActive(true); @@ -129,9 +162,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti const statusContent = formattedDate ? `${statusText ? `${statusText} ` : ''}(${formattedDate})` : statusText; const isStatusVisible = !!emojiCode && ReportUtils.isOneOnOneChat(!isEmptyObject(report) ? report : undefined); - const isGroupChat = ReportUtils.isGroupChat(optionItem) || ReportUtils.isDeprecatedGroupDM(optionItem); - - const fullTitle = isGroupChat ? ReportUtils.getGroupChatName(undefined, false, report) : optionItem.text; const subscriptAvatarBorderColor = isFocused ? focusedBackgroundColor : theme.sidebar; return ( <OfflineWithFeedback @@ -140,176 +170,199 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti shouldShowErrorMessages={false} needsOffscreenAlphaCompositing > - <Hoverable> - {(hovered) => ( - <PressableWithSecondaryInteraction - ref={popoverAnchor} - onPress={(event) => { - Performance.markStart(CONST.TIMING.OPEN_REPORT); + <EducationalTooltip + shouldRender={ + isFirstTimeNewExpensifyUser && + !shouldHideGBRTooltip && + hasCompletedGuidedSetupFlow && + isScreenFocused && + shouldUseNarrowLayout && + ReportUtils.isConciergeChatReport(report) + } + renderTooltipContent={renderGBRTooltip} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }} + shouldUseOverlay + shiftHorizontal={variables.gbrTooltipShiftHorizontal} + shiftVertical={variables.composerTooltipShiftVertical} + wrapperStyle={styles.quickActionTooltipWrapper} + onPressOverlay={() => User.dismissGBRTooltip()} + > + <View> + <Hoverable> + {(hovered) => ( + <PressableWithSecondaryInteraction + ref={popoverAnchor} + onPress={(event) => { + Performance.markStart(CONST.TIMING.OPEN_REPORT); - event?.preventDefault(); - // Enable Composer to focus on clicking the same chat after opening the context menu. - ReportActionComposeFocusManager.focus(); - onSelectRow(optionItem, popoverAnchor); - }} - onMouseDown={(event) => { - // Allow composer blur on right click - if (!event) { - return; - } + event?.preventDefault(); + // Enable Composer to focus on clicking the same chat after opening the context menu. + ReportActionComposeFocusManager.focus(); + onSelectRow(optionItem, popoverAnchor); + }} + onMouseDown={(event) => { + // Allow composer blur on right click + if (!event) { + return; + } - // Prevent composer blur on left click - event.preventDefault(); - }} - testID={optionItem.reportID} - onSecondaryInteraction={(event) => { - showPopover(event); - // Ensure that we blur the composer when opening context menu, so that only one component is focused at a time - if (DomUtils.getActiveElement()) { - (DomUtils.getActiveElement() as HTMLElement | null)?.blur(); - } - }} - withoutFocusOnSecondaryInteraction - activeOpacity={0.8} - style={[ - styles.flexRow, - styles.alignItemsCenter, - styles.justifyContentBetween, - styles.sidebarLink, - styles.sidebarLinkInnerLHN, - StyleUtils.getBackgroundColorStyle(theme.sidebar), - isFocused ? styles.sidebarLinkActive : null, - (hovered || isContextMenuActive) && !isFocused ? styles.sidebarLinkHover : null, - ]} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('accessibilityHints.navigatesToChat')} - onLayout={onLayout} - needsOffscreenAlphaCompositing={(optionItem?.icons?.length ?? 0) >= 2} - > - <View style={sidebarInnerRowStyle}> - <View style={[styles.flexRow, styles.alignItemsCenter]}> - {!!optionItem.icons?.length && - (optionItem.shouldShowSubscript ? ( - <SubscriptAvatar - backgroundColor={hovered && !isFocused ? hoveredBackgroundColor : subscriptAvatarBorderColor} - mainAvatar={optionItem.icons[0]} - secondaryAvatar={optionItem.icons[1]} - size={isInFocusMode ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT} - /> - ) : ( - <MultipleAvatars - icons={optionItem.icons} - isFocusMode={isInFocusMode} - size={isInFocusMode ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT} - secondAvatarStyle={[ - StyleUtils.getBackgroundAndBorderStyle(theme.sidebar), - isFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined, - hovered && !isFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, - ]} - shouldShowTooltip={OptionsListUtils.shouldOptionShowTooltip(optionItem)} - /> - ))} - <View style={contentContainerStyles}> - <View style={[styles.flexRow, styles.alignItemsCenter, styles.mw100, styles.overflowHidden]}> - <DisplayNames - accessibilityLabel={translate('accessibilityHints.chatUserDisplayNames')} - fullTitle={fullTitle ?? ''} - displayNamesWithTooltips={optionItem.displayNamesWithTooltips ?? []} - tooltipEnabled - numberOfLines={1} - textStyles={displayNameStyle} - shouldUseFullTitle={ - !!optionItem.isChatRoom || - !!optionItem.isPolicyExpenseChat || - !!optionItem.isTaskReport || - !!optionItem.isThread || - !!optionItem.isMoneyRequestReport || - !!optionItem.isInvoiceReport || - ReportUtils.isGroupChat(report) || - ReportUtils.isSystemChat(report) - } - /> - {ReportUtils.isChatUsedForOnboarding(report) && SubscriptionUtils.isUserOnFreeTrial() && ( - <Badge - success - text={translate('subscription.badge.freeTrial', {numOfDays: SubscriptionUtils.calculateRemainingFreeTrialDays()})} - badgeStyles={[styles.mnh0, styles.pl2, styles.pr2, styles.ml1]} - /> - )} - {isStatusVisible && ( - <Tooltip - text={statusContent} - shiftVertical={-4} - > - <Text style={styles.ml1}>{emojiCode}</Text> - </Tooltip> + // Prevent composer blur on left click + event.preventDefault(); + }} + testID={optionItem.reportID} + onSecondaryInteraction={(event) => { + showPopover(event); + // Ensure that we blur the composer when opening context menu, so that only one component is focused at a time + if (DomUtils.getActiveElement()) { + (DomUtils.getActiveElement() as HTMLElement | null)?.blur(); + } + }} + withoutFocusOnSecondaryInteraction + activeOpacity={0.8} + style={[ + styles.flexRow, + styles.alignItemsCenter, + styles.justifyContentBetween, + styles.sidebarLink, + styles.sidebarLinkInnerLHN, + StyleUtils.getBackgroundColorStyle(theme.sidebar), + isFocused ? styles.sidebarLinkActive : null, + (hovered || isContextMenuActive) && !isFocused ? styles.sidebarLinkHover : null, + ]} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('accessibilityHints.navigatesToChat')} + onLayout={onLayout} + needsOffscreenAlphaCompositing={(optionItem?.icons?.length ?? 0) >= 2} + > + <View style={sidebarInnerRowStyle}> + <View style={[styles.flexRow, styles.alignItemsCenter]}> + {!!optionItem.icons?.length && + (optionItem.shouldShowSubscript ? ( + <SubscriptAvatar + backgroundColor={hovered && !isFocused ? hoveredBackgroundColor : subscriptAvatarBorderColor} + mainAvatar={optionItem.icons[0]} + secondaryAvatar={optionItem.icons[1]} + size={isInFocusMode ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT} + /> + ) : ( + <MultipleAvatars + icons={optionItem.icons} + isFocusMode={isInFocusMode} + size={isInFocusMode ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT} + secondAvatarStyle={[ + StyleUtils.getBackgroundAndBorderStyle(theme.sidebar), + isFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined, + hovered && !isFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, + ]} + shouldShowTooltip={OptionsListUtils.shouldOptionShowTooltip(optionItem)} + /> + ))} + <View style={contentContainerStyles}> + <View style={[styles.flexRow, styles.alignItemsCenter, styles.mw100, styles.overflowHidden]}> + <DisplayNames + accessibilityLabel={translate('accessibilityHints.chatUserDisplayNames')} + fullTitle={optionItem.text ?? ''} + displayNamesWithTooltips={optionItem.displayNamesWithTooltips ?? []} + tooltipEnabled + numberOfLines={1} + textStyles={displayNameStyle} + shouldUseFullTitle={ + !!optionItem.isChatRoom || + !!optionItem.isPolicyExpenseChat || + !!optionItem.isTaskReport || + !!optionItem.isThread || + !!optionItem.isMoneyRequestReport || + !!optionItem.isInvoiceReport || + ReportUtils.isGroupChat(report) || + ReportUtils.isSystemChat(report) + } + /> + {ReportUtils.isChatUsedForOnboarding(report) && SubscriptionUtils.isUserOnFreeTrial() && ( + <Badge + success + text={translate('subscription.badge.freeTrial', {numOfDays: SubscriptionUtils.calculateRemainingFreeTrialDays()})} + badgeStyles={[styles.mnh0, styles.pl2, styles.pr2, styles.ml1]} + /> + )} + {isStatusVisible && ( + <Tooltip + text={statusContent} + shiftVertical={-4} + > + <Text style={styles.ml1}>{emojiCode}</Text> + </Tooltip> + )} + </View> + {optionItem.alternateText ? ( + <Text + style={alternateTextStyle} + numberOfLines={1} + accessibilityLabel={translate('accessibilityHints.lastChatMessagePreview')} + > + {Parser.htmlToText(optionItem.alternateText)} + </Text> + ) : null} + </View> + {optionItem?.descriptiveText ? ( + <View style={[styles.flexWrap]}> + <Text style={[styles.textLabel]}>{optionItem.descriptiveText}</Text> + </View> + ) : null} + {hasBrickError && ( + <View style={[styles.alignItemsCenter, styles.justifyContentCenter]}> + <Icon + src={Expensicons.DotIndicator} + fill={theme.danger} + /> + </View> )} </View> - {optionItem.alternateText ? ( - <Text - style={alternateTextStyle} - numberOfLines={1} - accessibilityLabel={translate('accessibilityHints.lastChatMessagePreview')} - > - {Parser.htmlToText(optionItem.alternateText)} - </Text> - ) : null} - </View> - {optionItem?.descriptiveText ? ( - <View style={[styles.flexWrap]}> - <Text style={[styles.textLabel]}>{optionItem.descriptiveText}</Text> - </View> - ) : null} - {hasBrickError && ( - <View style={[styles.alignItemsCenter, styles.justifyContentCenter]}> - <Icon - src={Expensicons.DotIndicator} - fill={theme.danger} - /> - </View> - )} - </View> - </View> - <View - style={[styles.flexRow, styles.alignItemsCenter]} - accessible={false} - > - {shouldShowGreenDotIndicator && ( - <View style={styles.ml2}> - <Icon - src={Expensicons.DotIndicator} - fill={theme.success} - /> </View> - )} - {hasDraftComment && optionItem.isAllowedToComment && ( <View - style={styles.ml2} - accessibilityLabel={translate('sidebarScreen.draftedMessage')} + style={[styles.flexRow, styles.alignItemsCenter]} + accessible={false} > - <Icon - testID="Pencil Icon" - fill={theme.icon} - src={Expensicons.Pencil} - /> - </View> - )} - {!shouldShowGreenDotIndicator && !hasBrickError && optionItem.isPinned && ( - <View - style={styles.ml2} - accessibilityLabel={translate('sidebarScreen.chatPinned')} - > - <Icon - testID="Pin Icon" - fill={theme.icon} - src={Expensicons.Pin} - /> + {shouldShowGreenDotIndicator && ( + <View style={styles.ml2}> + <Icon + src={Expensicons.DotIndicator} + fill={theme.success} + /> + </View> + )} + {hasDraftComment && optionItem.isAllowedToComment && ( + <View + style={styles.ml2} + accessibilityLabel={translate('sidebarScreen.draftedMessage')} + > + <Icon + testID="Pencil Icon" + fill={theme.icon} + src={Expensicons.Pencil} + /> + </View> + )} + {!shouldShowGreenDotIndicator && !hasBrickError && optionItem.isPinned && ( + <View + style={styles.ml2} + accessibilityLabel={translate('sidebarScreen.chatPinned')} + > + <Icon + testID="Pin Icon" + fill={theme.icon} + src={Expensicons.Pin} + /> + </View> + )} </View> - )} - </View> - </PressableWithSecondaryInteraction> - )} - </Hoverable> + </PressableWithSecondaryInteraction> + )} + </Hoverable> + </View> + </EducationalTooltip> </OfflineWithFeedback> ); } diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index 8179aed9de0c..8253a1708c81 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -28,6 +28,7 @@ function OptionRowLHNData({ transaction, lastReportActionTransaction, transactionViolations, + lastMessageTextFromReport, ...propsToForward }: OptionRowLHNDataProps) { const reportID = propsToForward.reportID; @@ -49,6 +50,7 @@ function OptionRowLHNData({ policy, parentReportAction, hasViolations: !!shouldDisplayViolations || shouldDisplayReportViolations, + lastMessageTextFromReport, transactionViolations, invoiceReceiverPolicy, }); @@ -76,6 +78,7 @@ function OptionRowLHNData({ receiptTransactions, invoiceReceiverPolicy, shouldDisplayReportViolations, + lastMessageTextFromReport, ]); return ( diff --git a/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx b/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx new file mode 100644 index 000000000000..ff050f673951 --- /dev/null +++ b/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx @@ -0,0 +1,16 @@ +import {CellContainer} from '@shopify/flash-list'; +import type {CellContainerProps} from '@shopify/flash-list/dist/native/cell-container/CellContainer'; + +function OptionRowRendererComponent(props: CellContainerProps) { + return ( + <CellContainer + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + style={[props.style, {zIndex: -props.index}]} + /> + ); +} + +OptionRowRendererComponent.displayName = 'OptionRowRendererComponent'; + +export default OptionRowRendererComponent; diff --git a/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx b/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx new file mode 100644 index 000000000000..25afb0124e9f --- /dev/null +++ b/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx @@ -0,0 +1,3 @@ +const OptionRowRendererComponent = undefined; + +export default OptionRowRendererComponent; diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index f914b001aba6..8b8071e6a3af 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -88,6 +88,9 @@ type OptionRowLHNDataProps = { /** Toggle between compact and default view */ viewMode?: OptionMode; + /** The last message text from the report */ + lastMessageTextFromReport: string; + /** A function that is called when an option is selected. Selected option is passed as a param */ onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject<View>) => void; diff --git a/src/components/LocationPermissionModal/index.android.tsx b/src/components/LocationPermissionModal/index.android.tsx index 811537e00e67..30896cf37084 100644 --- a/src/components/LocationPermissionModal/index.android.tsx +++ b/src/components/LocationPermissionModal/index.android.tsx @@ -63,11 +63,17 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe setHasError(false); }; + const closeModal = () => { + setShowModal(false); + resetPermissionFlow(); + }; + return ( <ConfirmModal isVisible={showModal} onConfirm={grantLocationPermission} onCancel={skipLocationPermission} + onBackdropPress={closeModal} confirmText={hasError ? translate('common.settings') : translate('common.continue')} cancelText={translate('common.notNow')} prompt={translate(hasError ? 'receipt.locationErrorMessage' : 'receipt.locationAccessMessage')} diff --git a/src/components/LocationPermissionModal/index.tsx b/src/components/LocationPermissionModal/index.tsx index 53fa6928b90d..0e500a9b7cc4 100644 --- a/src/components/LocationPermissionModal/index.tsx +++ b/src/components/LocationPermissionModal/index.tsx @@ -77,11 +77,16 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe return isWeb ? translate('common.buttonConfirm') : translate('common.settings'); }; + const closeModal = () => { + setShowModal(false); + resetPermissionFlow(); + }; return ( <ConfirmModal isVisible={showModal} onConfirm={grantLocationPermission} onCancel={skipLocationPermission} + onBackdropPress={closeModal} confirmText={getConfirmText()} cancelText={translate('common.notNow')} prompt={translate(hasError ? 'receipt.locationErrorMessage' : 'receipt.locationAccessMessage')} diff --git a/src/components/Lottie/index.tsx b/src/components/Lottie/index.tsx index 7c85837644a2..ea30161175e7 100644 --- a/src/components/Lottie/index.tsx +++ b/src/components/Lottie/index.tsx @@ -1,12 +1,15 @@ +import {NavigationContainerRefContext, NavigationContext} from '@react-navigation/native'; import type {AnimationObject, LottieViewProps} from 'lottie-react-native'; import LottieView from 'lottie-react-native'; import type {ForwardedRef} from 'react'; -import React, {forwardRef, useEffect, useState} from 'react'; +import React, {forwardRef, useContext, useEffect, useState} from 'react'; import {View} from 'react-native'; import type DotLottieAnimation from '@components/LottieAnimations/types'; import useAppState from '@hooks/useAppState'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as Browser from '@libs/Browser'; +import isSideModalNavigator from '@libs/Navigation/isSideModalNavigator'; import CONST from '@src/CONST'; import {useSplashScreenStateContext} from '@src/SplashScreenStateContext'; @@ -30,11 +33,40 @@ function Lottie({source, webStyle, ...props}: Props, ref: ForwardedRef<LottieVie const aspectRatioStyle = styles.aspectRatioLottie(source); - // If the image fails to load, app is in background state, animation file isn't ready, or the splash screen isn't hidden yet, + const browser = Browser.getBrowser(); + const [hasNavigatedAway, setHasNavigatedAway] = React.useState(false); + const navigationContainerRef = useContext(NavigationContainerRefContext); + const navigator = useContext(NavigationContext); + + useEffect(() => { + if (!browser || !navigationContainerRef || !navigator) { + return; + } + const unsubscribeNavigationFocus = navigator.addListener('focus', () => { + setHasNavigatedAway(false); + }); + return unsubscribeNavigationFocus; + }, [browser, navigationContainerRef, navigator]); + + useEffect(() => { + if (!browser || !navigationContainerRef || !navigator) { + return; + } + const unsubscribeNavigationBlur = navigator.addListener('blur', () => { + const state = navigationContainerRef.getRootState(); + const targetRouteName = state?.routes?.[state?.index ?? 0]?.name; + if (!isSideModalNavigator(targetRouteName)) { + setHasNavigatedAway(true); + } + }); + return unsubscribeNavigationBlur; + }, [browser, navigationContainerRef, navigator]); + + // If the page navigates to another screen, the image fails to load, app is in background state, animation file isn't ready, or the splash screen isn't hidden yet, // we'll just render an empty view as the fallback to prevent // 1. memory leak, see issue: https://github.com/Expensify/App/issues/36645 - // 2. heavy rendering, see issue: https://github.com/Expensify/App/issues/34696 - if (isError || appState.isBackground || !animationFile || splashScreenState !== CONST.BOOT_SPLASH_STATE.HIDDEN) { + // 2. heavy rendering, see issues: https://github.com/Expensify/App/issues/34696 and https://github.com/Expensify/App/issues/47273 + if (hasNavigatedAway || isError || appState.isBackground || !animationFile || splashScreenState !== CONST.BOOT_SPLASH_STATE.HIDDEN) { return <View style={[aspectRatioStyle, props.style]} />; } diff --git a/src/components/MenuItemList.tsx b/src/components/MenuItemList.tsx index c03bd712d74c..623198498dd1 100644 --- a/src/components/MenuItemList.tsx +++ b/src/components/MenuItemList.tsx @@ -3,8 +3,10 @@ import type {GestureResponderEvent, View} from 'react-native'; import useSingleExecution from '@hooks/useSingleExecution'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {MenuItemProps} from './MenuItem'; import MenuItem from './MenuItem'; +import OfflineWithFeedback from './OfflineWithFeedback'; type MenuItemLink = string | (() => Promise<string>); @@ -14,6 +16,18 @@ type MenuItemWithLink = MenuItemProps & { /** A unique key for the menu item */ key?: string; + + /** The pending action for the menu item */ + pendingAction?: OnyxCommon.PendingAction | null; + + /** A function to dismiss the pending action */ + onPendingActionDismiss?: () => void; + + /** The error for the menu item */ + error?: OnyxCommon.Errors | null; + + /** Whether we should force opacity */ + shouldForceOpacity?: boolean; }; type MenuItemListProps = { @@ -45,16 +59,23 @@ function MenuItemList({menuItems = [], shouldUseSingleExecution = false}: MenuIt return ( <> {menuItems.map((menuItemProps) => ( - <MenuItem - key={menuItemProps.key ?? menuItemProps.title} - onSecondaryInteraction={menuItemProps.link !== undefined ? (e) => secondaryInteraction(menuItemProps.link, e) : undefined} - ref={popoverAnchor} - shouldBlockSelection={!!menuItemProps.link} - // eslint-disable-next-line react/jsx-props-no-spreading - {...menuItemProps} - disabled={!!menuItemProps.disabled || isExecuting} - onPress={shouldUseSingleExecution ? singleExecution(menuItemProps.onPress) : menuItemProps.onPress} - /> + <OfflineWithFeedback + pendingAction={menuItemProps.pendingAction} + onClose={menuItemProps.onPendingActionDismiss} + errors={menuItemProps.error} + shouldForceOpacity={menuItemProps.shouldForceOpacity} + > + <MenuItem + key={menuItemProps.key ?? menuItemProps.title} + onSecondaryInteraction={menuItemProps.link !== undefined ? (e) => secondaryInteraction(menuItemProps.link, e) : undefined} + ref={popoverAnchor} + shouldBlockSelection={!!menuItemProps.link} + // eslint-disable-next-line react/jsx-props-no-spreading + {...menuItemProps} + disabled={!!menuItemProps.disabled || isExecuting} + onPress={shouldUseSingleExecution ? singleExecution(menuItemProps.onPress) : menuItemProps.onPress} + /> + </OfflineWithFeedback> ))} </> ); diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index d99ea617294b..96ffb7bca5cd 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -16,6 +16,7 @@ import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; import * as TransactionActions from '@userActions/Transaction'; import CONST from '@src/CONST'; +import useDelegateUserDetails from '@src/hooks/useDelegateUserDetails'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; @@ -25,6 +26,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import Button from './Button'; import ConfirmModal from './ConfirmModal'; +import DelegateNoAccessModal from './DelegateNoAccessModal'; import HeaderWithBackButton from './HeaderWithBackButton'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; @@ -140,6 +142,8 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const isAnyTransactionOnHold = ReportUtils.hasHeldExpenses(moneyRequestReport.reportID); const displayedAmount = isAnyTransactionOnHold && canAllowSettlement ? nonHeldAmount : formattedAmount; const isMoreContentShown = shouldShowNextStep || shouldShowStatusBar || (shouldShowAnyButton && shouldUseNarrowLayout); + const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); + const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const confirmPayment = (type?: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type || !chatReport) { @@ -147,7 +151,9 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea } setPaymentType(type); setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); - if (isAnyTransactionOnHold) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (isAnyTransactionOnHold) { setIsHoldMenuVisible(true); } else if (ReportUtils.isInvoiceReport(moneyRequestReport)) { IOU.payInvoice(type, chatReport, moneyRequestReport, payAsBusiness); @@ -158,7 +164,9 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const confirmApproval = () => { setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); - if (isAnyTransactionOnHold) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (isAnyTransactionOnHold) { setIsHoldMenuVisible(true); } else { IOU.approveMoneyRequest(moneyRequestReport, true); @@ -400,6 +408,12 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea transactionCount={transactionIDs.length} /> )} + <DelegateNoAccessModal + isNoDelegateAccessMenuVisible={isNoDelegateAccessMenuVisible} + onClose={() => setIsNoDelegateAccessMenuVisible(false)} + delegatorEmail={delegatorEmail ?? ''} + /> + <ConfirmModal title={translate('iou.deleteExpense')} isVisible={isDeleteRequestModalVisible} diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 8eda292a6ede..743a5b276c98 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -166,6 +166,9 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** Should play sound on confirmation */ shouldPlaySound?: boolean; + + /** Whether the expense is confirmed or not */ + isConfirmed?: boolean; }; type MoneyRequestConfirmationListItem = Participant | ReportUtils.OptionData; @@ -209,6 +212,7 @@ function MoneyRequestConfirmationList({ currencyList, shouldDisplayReceipt = false, shouldPlaySound = true, + isConfirmed, }: MoneyRequestConfirmationListProps) { const policy = policyReal ?? policyDraft; const policyCategories = policyCategoriesReal ?? policyCategoriesDraft; @@ -300,7 +304,7 @@ function MoneyRequestConfirmationList({ const isFocused = useIsFocused(); const [formError, debouncedFormError, setFormError] = useDebouncedState<TranslationPaths | ''>(''); - const [didConfirm, setDidConfirm] = useState(false); + const [didConfirm, setDidConfirm] = useState(isConfirmed); const [didConfirmSplit, setDidConfirmSplit] = useState(false); const shouldDisplayFieldError: boolean = useMemo(() => { @@ -420,6 +424,10 @@ function MoneyRequestConfirmationList({ setDidConfirm(false); } + useEffect(() => { + setDidConfirm(isConfirmed); + }, [isConfirmed]); + const splitOrRequestOptions: Array<DropdownOption<string>> = useMemo(() => { let text; if (isTypeInvoice) { @@ -776,7 +784,6 @@ function MoneyRequestConfirmationList({ if (shouldPlaySound) { playSound(SOUNDS.DONE); } - setDidConfirm(true); onConfirm?.(selectedParticipants); } else { if (!paymentMethod) { @@ -785,9 +792,6 @@ function MoneyRequestConfirmationList({ if (formError) { return; } - - setDidConfirm(true); - Log.info(`[IOU] Sending money via: ${paymentMethod}`); onSendMoney?.(paymentMethod); } @@ -843,6 +847,7 @@ function MoneyRequestConfirmationList({ onPress={confirm} enablePaymentsRoute={ROUTES.IOU_SEND_ENABLE_PAYMENTS} addBankAccountRoute={bankAccountRoute} + shouldShowPersonalBankAccountOption currency={iouCurrencyCode} policyID={policyID} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} @@ -906,7 +911,7 @@ function MoneyRequestConfirmationList({ action={action} canUseP2PDistanceRequests={canUseP2PDistanceRequests} currency={currency} - didConfirm={didConfirm} + didConfirm={!!didConfirm} distance={distance} formattedAmount={formattedAmount} formError={formError} diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index d56f60b7a8d2..32e5a128bcf0 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -60,6 +60,9 @@ type OfflineWithFeedbackProps = ChildrenProps & { /** Whether we should render the error message above the children */ shouldDisplayErrorAbove?: boolean; + + /** Whether we should force opacity */ + shouldForceOpacity?: boolean; }; type StrikethroughProps = Partial<ChildrenProps> & {style: AllStyles[]}; @@ -78,6 +81,7 @@ function OfflineWithFeedback({ shouldShowErrorMessages = true, style, shouldDisplayErrorAbove = false, + shouldForceOpacity = false, ...rest }: OfflineWithFeedbackProps) { const styles = useThemeStyles(); @@ -89,7 +93,7 @@ function OfflineWithFeedback({ const isOfflinePendingAction = !!isOffline && !!pendingAction; const isUpdateOrDeleteError = hasErrors && (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); const isAddError = hasErrors && pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; - const needsOpacity = !shouldDisableOpacity && ((isOfflinePendingAction && !isUpdateOrDeleteError) || isAddError); + const needsOpacity = (!shouldDisableOpacity && ((isOfflinePendingAction && !isUpdateOrDeleteError) || isAddError)) || shouldForceOpacity; const needsStrikeThrough = !shouldDisableStrikeThrough && isOffline && pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const hideChildren = shouldHideOnDelete && !isOffline && pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !hasErrors; let children = rest.children; @@ -138,7 +142,7 @@ function OfflineWithFeedback({ )} {!hideChildren && ( <View - style={[needsOpacity ? styles.offlineFeedback.pending : {}, contentContainerStyle]} + style={[needsOpacity ? styles.offlineFeedback.pending : styles.offlineFeedback.default, contentContainerStyle]} needsOffscreenAlphaCompositing={shouldRenderOffscreen ? needsOpacity && needsOffscreenAlphaCompositing : undefined} > <CustomStylesForChildrenProvider style={needsStrikeThrough ? [styles.offlineFeedback.deleted, styles.userSelectNone] : null}>{children}</CustomStylesForChildrenProvider> diff --git a/src/components/PercentageForm.tsx b/src/components/PercentageForm.tsx new file mode 100644 index 000000000000..8d9ca950f49c --- /dev/null +++ b/src/components/PercentageForm.tsx @@ -0,0 +1,102 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useMemo, useRef, useState} from 'react'; +import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import CONST from '@src/CONST'; +import TextInput from './TextInput'; +import type {BaseTextInputRef} from './TextInput/BaseTextInput/types'; + +type PercentageFormProps = { + /** Amount supplied by the FormProvider */ + value?: string; + + /** Error to display at the bottom of the component */ + errorText?: string; + + /** Callback to update the amount in the FormProvider */ + onInputChange?: (value: string) => void; + + /** Custom label for the TextInput */ + label?: string; +}; + +/** + * Returns the new selection object based on the updated amount's length + */ +const getNewSelection = (oldSelection: {start: number; end: number}, prevLength: number, newLength: number) => { + const cursorPosition = oldSelection.end + (newLength - prevLength); + return {start: cursorPosition, end: cursorPosition}; +}; + +function PercentageForm({value: amount, errorText, onInputChange, label, ...rest}: PercentageFormProps, forwardedRef: ForwardedRef<BaseTextInputRef>) { + const {toLocaleDigit, numberFormat} = useLocalize(); + + const textInput = useRef<BaseTextInputRef | null>(null); + + const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); + + const [selection, setSelection] = useState({ + start: currentAmount.length, + end: currentAmount.length, + }); + + const forwardDeletePressedRef = useRef(false); + + /** + * Sets the selection and the amount accordingly to the value passed to the input + * @param newAmount - Changed amount from user input + */ + const setNewAmount = useCallback( + (newAmount: string) => { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); + // Use a shallow copy of selection to trigger setSelection + // More info: https://github.com/Expensify/App/issues/16385 + if (!MoneyRequestUtils.validatePercentage(newAmountWithoutSpaces)) { + setSelection((prevSelection) => ({...prevSelection})); + return; + } + + const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); + const isForwardDelete = currentAmount.length > strippedAmount.length && forwardDeletePressedRef.current; + setSelection(getNewSelection(selection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length)); + onInputChange?.(strippedAmount); + }, + [currentAmount, onInputChange, selection], + ); + + const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); + + return ( + <TextInput + label={label} + value={formattedAmount} + onChangeText={setNewAmount} + placeholder={numberFormat(0)} + ref={(ref: BaseTextInputRef) => { + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef && 'current' in forwardedRef) { + // eslint-disable-next-line no-param-reassign + forwardedRef.current = ref; + } + textInput.current = ref; + }} + selection={selection} + onSelectionChange={(e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { + setSelection(e.nativeEvent.selection); + }} + suffixCharacter="%" + keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + /> + ); +} + +PercentageForm.displayName = 'PercentageForm'; + +export default forwardRef(PercentageForm); +export type {PercentageFormProps}; diff --git a/src/components/PromotedActionsBar.tsx b/src/components/PromotedActionsBar.tsx index b0309d702f9a..bf7b1aeff003 100644 --- a/src/components/PromotedActionsBar.tsx +++ b/src/components/PromotedActionsBar.tsx @@ -29,7 +29,13 @@ type BasePromotedActions = typeof CONST.PROMOTED_ACTIONS.PIN | typeof CONST.PROM type PromotedActionsType = Record<BasePromotedActions, (report: OnyxReport) => PromotedAction> & { message: (params: {reportID?: string; accountID?: number; login?: string}) => PromotedAction; } & { - hold: (params: {isTextHold: boolean; reportAction: ReportAction | undefined; reportID?: string}) => PromotedAction; + hold: (params: { + isTextHold: boolean; + reportAction: ReportAction | undefined; + reportID?: string; + isDelegateAccessRestricted: boolean; + setIsNoDelegateAccessMenuVisible: (isVisible: boolean) => void; + }) => PromotedAction; }; const PromotedActions = { @@ -70,11 +76,16 @@ const PromotedActions = { } }, }), - hold: ({isTextHold, reportAction, reportID}) => ({ + hold: ({isTextHold, reportAction, reportID, isDelegateAccessRestricted, setIsNoDelegateAccessMenuVisible}) => ({ key: CONST.PROMOTED_ACTIONS.HOLD, icon: Expensicons.Stopwatch, text: Localize.translateLocal(`iou.${isTextHold ? 'hold' : 'unhold'}`), onSelected: () => { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); // Show the menu + return; + } + if (!isTextHold) { Navigation.goBack(); } diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 6ab1c0937278..f422269bfc69 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -136,7 +136,7 @@ function MoneyRequestPreviewContent({ const duplicates = useMemo(() => TransactionUtils.removeSettledAndApprovedTransactions(allDuplicates), [allDuplicates]); // When there are no settled transactions in duplicates, show the "Keep this one" button - const shouldShowKeepButton = allDuplicates.length === duplicates.length; + const shouldShowKeepButton = !!(allDuplicates.length && duplicates.length && allDuplicates.length === duplicates.length); const hasDuplicates = duplicates.length > 0; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 57c5c01da55d..1edce17cd242 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; @@ -20,7 +20,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useViolations from '@hooks/useViolations'; import type {ViolationField} from '@hooks/useViolations'; import * as CurrencyUtils from '@libs/CurrencyUtils'; -import type {MileageRate} from '@libs/DistanceRequestUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -47,32 +46,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; import ReportActionItemImage from './ReportActionItemImage'; -type MoneyRequestViewTransactionOnyxProps = { - /** Violations detected in this transaction */ - transactionViolations: OnyxEntry<OnyxTypes.TransactionViolations>; -}; - -type MoneyRequestViewOnyxPropsWithoutTransaction = { - /** The policy object for the current route */ - policy: OnyxEntry<OnyxTypes.Policy>; - - /** Collection of categories attached to a policy */ - policyCategories: OnyxEntry<OnyxTypes.PolicyCategories>; - - /** Collection of tags attached to a policy */ - policyTagList: OnyxEntry<OnyxTypes.PolicyTagLists>; - - /** The expense report or iou report (only will have a value if this is a transaction thread) */ - parentReport: OnyxEntry<OnyxTypes.Report>; - - /** The actions from the parent report */ - parentReportActions: OnyxEntry<OnyxTypes.ReportActions>; - - /** The distance rates from the policy */ - distanceRates: Record<string, MileageRate>; -}; - -type MoneyRequestViewPropsWithoutTransaction = MoneyRequestViewOnyxPropsWithoutTransaction & { +type MoneyRequestViewProps = { /** The report currently being looked at */ report: OnyxEntry<OnyxTypes.Report>; @@ -86,8 +60,6 @@ type MoneyRequestViewPropsWithoutTransaction = MoneyRequestViewOnyxPropsWithoutT updatedTransaction?: OnyxEntry<OnyxTypes.Transaction>; }; -type MoneyRequestViewProps = MoneyRequestViewTransactionOnyxProps & MoneyRequestViewPropsWithoutTransaction; - const receiptImageViolationNames: OnyxTypes.ViolationName[] = [ CONST.VIOLATIONS.RECEIPT_REQUIRED, CONST.VIOLATIONS.RECEIPT_NOT_SMART_SCANNED, @@ -115,28 +87,29 @@ const getTransactionID = (report: OnyxEntry<OnyxTypes.Report>, parentReportActio return originalMessage?.IOUTransactionID ?? -1; }; -function MoneyRequestView({ - report, - parentReport, - parentReportActions, - policyCategories, - policyTagList, - policy, - transactionViolations, - shouldShowAnimatedBackground, - distanceRates, - readonly = false, - updatedTransaction, -}: MoneyRequestViewProps) { +function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = false, updatedTransaction}: MoneyRequestViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const session = useSession(); const {isOffline} = useNetwork(); const {translate, toLocaleDigit} = useLocalize(); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const parentReportID = report?.parentReportID ?? '-1'; + const policyID = report?.policyID ?? '-1'; + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReport?.parentReportID}`, { selector: (chatReportValue) => chatReportValue && {reportID: chatReportValue.reportID, errorFields: chatReportValue.errorFields}, }); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const [policyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + canEvict: false, + }); + const [distanceRates = {}] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + selector: () => DistanceRequestUtils.getMileageRates(policy, true), + }); + const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${getTransactionID(report, parentReportActions)}`); const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1']; const isTrackExpense = ReportUtils.isTrackExpenseReport(report); @@ -194,8 +167,8 @@ function MoneyRequestView({ const canEditMerchant = canUserPerformWriteAction && ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.MERCHANT); const canEditDate = canUserPerformWriteAction && ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.DATE); const canEditReceipt = canUserPerformWriteAction && ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT); - const hasReceipt = TransactionUtils.hasReceipt(transaction); - const isReceiptBeingScanned = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction); + const hasReceipt = TransactionUtils.hasReceipt(updatedTransaction ?? transaction); + const isReceiptBeingScanned = hasReceipt && TransactionUtils.isReceiptBeingScanned(updatedTransaction ?? transaction); const didReceiptScanSucceed = hasReceipt && TransactionUtils.didReceiptScanSucceed(transaction); const canEditDistance = canUserPerformWriteAction && ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.DISTANCE); const canEditDistanceRate = canUserPerformWriteAction && ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE); @@ -257,6 +230,8 @@ function MoneyRequestView({ } return TransactionUtils.getDescription(updatedTransaction ?? null); }, [updatedTransaction]); + const isEmptyUpdatedMerchant = updatedTransaction?.modifiedMerchant === '' || updatedTransaction?.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; + const updatedMerchantTitle = isEmptyUpdatedMerchant ? '' : updatedTransaction?.modifiedMerchant ?? merchantTitle; const saveBillable = useCallback( (newBillable: boolean) => { @@ -292,7 +267,7 @@ function MoneyRequestView({ let receiptURIs; const hasErrors = TransactionUtils.hasMissingSmartscanFields(transaction); if (hasReceipt) { - receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction); + receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(updatedTransaction ?? transaction); } const pendingAction = transaction?.pendingAction; const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction; @@ -496,7 +471,7 @@ function MoneyRequestView({ image={receiptURIs?.image} isLocalFile={receiptURIs?.isLocalFile} filename={receiptURIs?.filename} - transaction={transaction} + transaction={updatedTransaction ?? transaction} enablePreviewModal readonly={readonly} /> @@ -562,7 +537,7 @@ function MoneyRequestView({ <OfflineWithFeedback pendingAction={getPendingFieldAction('merchant')}> <MenuItemWithTopDescription description={translate('common.merchant')} - title={updatedTransaction?.modifiedMerchant ?? merchantTitle} + title={updatedMerchantTitle} interactive={canEditMerchant} shouldShowRightIcon={canEditMerchant} titleStyle={styles.flex1} @@ -688,32 +663,4 @@ function MoneyRequestView({ MoneyRequestView.displayName = 'MoneyRequestView'; -export default withOnyx<MoneyRequestViewPropsWithoutTransaction, MoneyRequestViewOnyxPropsWithoutTransaction>({ - // Fallback to empty string will fetch the whole collection (e.g., policy_), so we need to fallback to -1 (policy_-1) - policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`, - }, - policyCategories: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID ?? '-1'}`, - }, - policyTagList: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID ?? '-1'}`, - }, - parentReport: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? '-1'}`, - }, - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report?.parentReportID : '-1'}`, - canEvict: false, - }, - distanceRates: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, - selector: (policy: OnyxEntry<OnyxTypes.Policy>) => DistanceRequestUtils.getMileageRates(policy, true), - }, -})( - withOnyx<MoneyRequestViewProps, MoneyRequestViewTransactionOnyxProps>({ - transactionViolations: { - key: ({report, parentReportActions}) => `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${getTransactionID(report, parentReportActions)}`, - }, - })(MoneyRequestView), -); +export default MoneyRequestView; diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 718b60828f62..d967a914c9f9 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -99,7 +99,7 @@ function ReportActionItemImage({ const attachmentModalSource = tryResolveUrlFromApiRoot(image ?? ''); const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail ?? ''); - const isEReceipt = transaction && TransactionUtils.hasEReceipt(transaction); + const isEReceipt = transaction && !TransactionUtils.hasReceiptSource(transaction) && TransactionUtils.hasEReceipt(transaction); let propsObj: ReceiptImageProps; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 792ebb176900..bfcf17ef617b 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -5,6 +5,7 @@ import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useOnyx, withOnyx} from 'react-native-onyx'; import Button from '@components/Button'; +import DelegateNoAccessModal from '@components/DelegateNoAccessModal'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -14,6 +15,7 @@ import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; import SettlementButton from '@components/SettlementButton'; import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; +import useDelegateUserDetails from '@hooks/useDelegateUserDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; @@ -191,13 +193,18 @@ function ReportPreview({ [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled], ); + const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); + const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); + const confirmPayment = (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type) { return; } setPaymentType(type); setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); - if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else if (chatReport && iouReport) { if (ReportUtils.isInvoiceReport(iouReport)) { @@ -210,7 +217,9 @@ function ReportPreview({ const confirmApproval = () => { setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); - if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else { IOU.approveMoneyRequest(iouReport, true); @@ -516,6 +525,12 @@ function ReportPreview({ </View> </PressableWithoutFeedback> </View> + <DelegateNoAccessModal + isNoDelegateAccessMenuVisible={isNoDelegateAccessMenuVisible} + onClose={() => setIsNoDelegateAccessMenuVisible(false)} + delegatorEmail={delegatorEmail ?? ''} + /> + {isHoldMenuVisible && iouReport && requestType !== undefined && ( <ProcessMoneyReportHoldMenu nonHeldAmount={!hasOnlyHeldExpenses ? nonHeldAmount : undefined} diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index ca1324a31fa7..f5604b030aa5 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -1,8 +1,9 @@ import {Str} from 'expensify-common'; import React from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx, withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import Avatar from '@components/Avatar'; import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -73,8 +74,8 @@ function TaskPreview({taskReport, taskReportID, action, contextMenuAnchor, chatR : action?.childStateNum === CONST.REPORT.STATE_NUM.APPROVED && action?.childStatusNum === CONST.REPORT.STATUS_NUM.APPROVED; const taskTitle = Str.htmlEncode(TaskUtils.getTaskTitle(taskReportID, action?.childReportName ?? '')); const taskAssigneeAccountID = Task.getTaskAssigneeAccountID(taskReport) ?? action?.childManagerAccountID ?? -1; - const htmlForTaskPreview = - taskAssigneeAccountID > 0 ? `<comment><mention-user accountid="${taskAssigneeAccountID}"></mention-user> ${taskTitle}</comment>` : `<comment>${taskTitle}</comment>`; + const [avatar] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (personalDetails) => personalDetails?.[taskAssigneeAccountID]?.avatar}); + const htmlForTaskPreview = `<comment>${taskTitle}</comment>`; const isDeletedParentAction = ReportUtils.isCanceledTaskReport(taskReport, action); if (isDeletedParentAction) { @@ -93,11 +94,10 @@ function TaskPreview({taskReport, taskReportID, action, contextMenuAnchor, chatR role={CONST.ROLE.BUTTON} accessibilityLabel={translate('task.task')} > - <View style={[styles.flex1, styles.flexRow, styles.alignItemsStart]}> - <View style={[styles.taskCheckboxWrapper]}> + <View style={[styles.flex1, styles.flexRow, styles.alignItemsStart, styles.mt1]}> + <View style={[styles.taskCheckboxWrapper, styles.alignSelfCenter]}> <Checkbox style={[styles.mr2]} - containerStyle={[styles.taskCheckbox]} isChecked={isTaskCompleted} disabled={!Task.canModifyTask(taskReport, currentUserPersonalDetails.accountID)} onPress={Session.checkIfActionIsAllowed(() => { @@ -110,7 +110,18 @@ function TaskPreview({taskReport, taskReportID, action, contextMenuAnchor, chatR accessibilityLabel={translate('task.task')} /> </View> - <RenderHTML html={isTaskCompleted ? `<completed-task>${htmlForTaskPreview}</completed-task>` : htmlForTaskPreview} /> + {taskAssigneeAccountID > 0 && ( + <Avatar + containerStyles={[styles.mr2, styles.alignSelfCenter, isTaskCompleted ? styles.opacitySemiTransparent : undefined]} + source={avatar} + size={CONST.AVATAR_SIZE.SMALL} + avatarID={taskAssigneeAccountID} + type={CONST.ICON_TYPE_AVATAR} + /> + )} + <View style={[styles.alignSelfCenter]}> + <RenderHTML html={isTaskCompleted ? `<completed-task>${htmlForTaskPreview}</completed-task>` : htmlForTaskPreview} /> + </View> </View> <Icon src={Expensicons.ArrowRight} diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 76fd7d3524cb..714b388b7331 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -10,7 +10,8 @@ import type HeaderWithBackButtonProps from '@components/HeaderWithBackButton/typ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; -import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import Text from '@components/Text'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; @@ -18,9 +19,9 @@ import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import * as SearchActions from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import {getAllTaxRates} from '@libs/PolicyUtils'; import * as SearchUtils from '@libs/SearchUtils'; import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow'; import variables from '@styles/variables'; @@ -95,7 +96,7 @@ type SearchPageHeaderProps = { onSelectDeleteOption?: (itemsToDelete: string[]) => void; setOfflineModalOpen?: () => void; setDownloadErrorModalOpen?: () => void; - data?: TransactionListItemType[] | ReportListItemType[]; + data?: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[]; }; type SearchHeaderOptionValue = DeepValueOf<typeof CONST.SEARCH.BULK_ACTION_TYPES> | undefined; @@ -111,6 +112,8 @@ function getHeaderContent(type: SearchDataTypes): HeaderContent { return {icon: Illustrations.EnvelopeReceipt, titleText: 'workspace.common.invoices'}; case CONST.SEARCH.DATA_TYPES.TRIP: return {icon: Illustrations.Luggage, titleText: 'travel.trips'}; + case CONST.SEARCH.DATA_TYPES.CHAT: + return {icon: Illustrations.CommentBubblesBlue, titleText: 'common.chats'}; case CONST.SEARCH.DATA_TYPES.EXPENSE: default: return {icon: Illustrations.MoneyReceipts, titleText: 'common.expenses'}; @@ -124,8 +127,12 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa const {isOffline} = useNetwork(); const {activeWorkspaceID} = useActiveWorkspace(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {selectedTransactions, clearSelectedTransactions} = useSearchContext(); + const {selectedTransactions} = useSearchContext(); const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); + const personalDetails = usePersonalDetails(); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const taxRates = getAllTaxRates(); + const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); @@ -135,6 +142,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa .filter( (item) => !SearchUtils.isTransactionListItemType(item) && + !SearchUtils.isReportActionListItemType(item) && item.reportID && item.transactions.every((transaction: {keyForList: string | number}) => selectedTransactions[transaction.keyForList]?.isSelected), ) @@ -145,7 +153,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa const isCannedQuery = SearchUtils.isCannedSearchQuery(queryJSON); - const headerSubtitle = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchUtils.getSearchHeaderTitle(queryJSON); + const headerSubtitle = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchUtils.getSearchHeaderTitle(queryJSON, personalDetails, cardList, reports, taxRates); const headerTitle = isCannedQuery ? '' : translate('search.filtersHeader'); const headerIcon = isCannedQuery ? getHeaderContent(type).icon : Illustrations.Filters; @@ -193,9 +201,6 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa return; } - if (selectionMode?.isEnabled) { - turnOffMobileSelectionMode(); - } Navigation.navigate(ROUTES.TRANSACTION_HOLD_REASON_RHP); }, }); @@ -215,10 +220,6 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa return; } - clearSelectedTransactions(); - if (selectionMode?.isEnabled) { - turnOffMobileSelectionMode(); - } SearchActions.unholdMoneyRequestOnSearch(hash, selectedTransactionsKeys); }, }); @@ -269,7 +270,6 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa selectedTransactions, translate, onSelectDeleteOption, - clearSelectedTransactions, hash, theme.icon, styles.colorMuted, @@ -280,7 +280,6 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa activeWorkspaceID, selectedReports, styles.textWrap, - selectionMode?.isEnabled, ]); if (shouldUseNarrowLayout) { @@ -295,6 +294,12 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa return null; } + const onPress = () => { + const values = SearchUtils.getFiltersFormValues(queryJSON); + SearchActions.updateAdvancedFilters(values); + Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS); + }; + return ( <HeaderWrapper title={headerTitle} @@ -302,7 +307,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa icon={headerIcon} subtitleStyles={subtitleStyles} > - {headerButtonsOptions.length > 0 && ( + {headerButtonsOptions.length > 0 ? ( <ButtonWithDropdownMenu onPress={() => null} shouldAlwaysShowDropdownMenu @@ -312,13 +317,14 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa options={headerButtonsOptions} isSplitButton={false} /> + ) : ( + <Button + text={translate('search.filtersHeader')} + icon={Expensicons.Filters} + onPress={onPress} + medium + /> )} - <Button - text={translate('search.filtersHeader')} - icon={Expensicons.Filters} - onPress={() => Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS)} - medium - /> </HeaderWrapper> ); } diff --git a/src/components/Search/SearchStatusBar.tsx b/src/components/Search/SearchStatusBar.tsx index 7c1ffeff1818..b8b2b3fd05d0 100644 --- a/src/components/Search/SearchStatusBar.tsx +++ b/src/components/Search/SearchStatusBar.tsx @@ -13,11 +13,12 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryString, SearchStatus, TripSearchStatus} from './types'; +import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryString, SearchStatus, TripSearchStatus} from './types'; type SearchStatusBarProps = { type: SearchDataTypes; status: SearchStatus; + resetOffset: () => void; }; const expenseOptions: Array<{key: ExpenseSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ @@ -82,28 +83,49 @@ const tripOptions: Array<{key: TripSearchStatus; icon: IconAsset; text: Translat query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.ALL), }, { - key: CONST.SEARCH.STATUS.TRIP.DRAFTS, - icon: Expensicons.Pencil, - text: 'common.drafts', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.DRAFTS), + key: CONST.SEARCH.STATUS.TRIP.CURRENT, + icon: Expensicons.Calendar, + text: 'search.filters.current', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.CURRENT), }, { - key: CONST.SEARCH.STATUS.TRIP.OUTSTANDING, - icon: Expensicons.Hourglass, - text: 'common.outstanding', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.OUTSTANDING), + key: CONST.SEARCH.STATUS.TRIP.PAST, + icon: Expensicons.History, + text: 'search.filters.past', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.PAST), }, +]; + +const chatOptions: Array<{key: ChatSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ { - key: CONST.SEARCH.STATUS.TRIP.APPROVED, - icon: Expensicons.ThumbsUp, - text: 'iou.approved', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.APPROVED), + key: CONST.SEARCH.STATUS.CHAT.ALL, + icon: Expensicons.All, + text: 'common.all', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.ALL), }, { - key: CONST.SEARCH.STATUS.TRIP.PAID, - icon: Expensicons.MoneyBag, - text: 'iou.settledExpensify', - query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.TRIP.PAID), + key: CONST.SEARCH.STATUS.CHAT.UNREAD, + icon: Expensicons.ChatBubbleUnread, + text: 'common.unread', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.UNREAD), + }, + { + key: CONST.SEARCH.STATUS.CHAT.SENT, + icon: Expensicons.Send, + text: 'common.sent', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.SENT), + }, + { + key: CONST.SEARCH.STATUS.CHAT.ATTACHMENTS, + icon: Expensicons.Document, + text: 'common.attachments', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.ATTACHMENTS), + }, + { + key: CONST.SEARCH.STATUS.CHAT.LINKS, + icon: Expensicons.Paperclip, + text: 'common.links', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.LINKS), }, ]; @@ -113,13 +135,15 @@ function getOptions(type: SearchDataTypes) { return invoiceOptions; case CONST.SEARCH.DATA_TYPES.TRIP: return tripOptions; + case CONST.SEARCH.DATA_TYPES.CHAT: + return chatOptions; case CONST.SEARCH.DATA_TYPES.EXPENSE: default: return expenseOptions; } } -function SearchStatusBar({type, status}: SearchStatusBarProps) { +function SearchStatusBar({type, status, resetOffset}: SearchStatusBarProps) { const {singleExecution} = useSingleExecution(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -134,7 +158,10 @@ function SearchStatusBar({type, status}: SearchStatusBarProps) { showsHorizontalScrollIndicator={false} > {options.map((item, index) => { - const onPress = singleExecution(() => Navigation.setParams({q: item.query})); + const onPress = singleExecution(() => { + resetOffset(); + Navigation.setParams({q: item.query}); + }); const isActive = status === item.key; const isFirstItem = index === 0; const isLastItem = index === options.length - 1; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 8c4530b08b64..8296b494b6fc 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -3,10 +3,11 @@ import type {StackNavigationProp} from '@react-navigation/stack'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; import SearchTableHeader from '@components/SelectionList/SearchTableHeader'; -import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import SearchStatusSkeleton from '@components/Skeletons/SearchStatusSkeleton'; @@ -54,7 +55,10 @@ function mapToTransactionItemWithSelectionInfo(item: TransactionListItemType, se return {...item, isSelected: selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple}; } -function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean) { +function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType | ReportActionListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean) { + if (SearchUtils.isReportActionListItemType(item)) { + return item; + } return SearchUtils.isTransactionListItemType(item) ? mapToTransactionItemWithSelectionInfo(item, selectedTransactions, canSelectMultiple) : { @@ -142,8 +146,8 @@ function Search({queryJSON}: SearchProps) { }; const getItemHeight = useCallback( - (item: TransactionListItemType | ReportListItemType) => { - if (SearchUtils.isTransactionListItemType(item)) { + (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { + if (SearchUtils.isTransactionListItemType(item) || SearchUtils.isReportActionListItemType(item)) { return isLargeScreenWidth ? variables.optionRowHeight + listItemPadding : transactionItemMobileHeight + listItemPadding; } @@ -161,6 +165,8 @@ function Search({queryJSON}: SearchProps) { [isLargeScreenWidth], ); + const resetOffset = () => setOffset(0); + const getItemHeightMemoized = memoize(getItemHeight, { transformKey: ([item]) => { // List items are displayed differently on "L"arge and "N"arrow screens so the height will differ @@ -205,6 +211,7 @@ function Search({queryJSON}: SearchProps) { <SearchStatusBar type={type} status={status} + resetOffset={resetOffset} /> ) : ( <SearchStatusSkeleton shouldAnimate /> @@ -216,12 +223,12 @@ function Search({queryJSON}: SearchProps) { if (searchResults === undefined) { Log.alert('[Search] Undefined search type'); - return null; + return <FullPageOfflineBlockingView>{null}</FullPageOfflineBlockingView>; } - const ListItem = SearchUtils.getListItem(status); - const data = SearchUtils.getSections(status, searchResults.data, searchResults.search); - const sortedData = SearchUtils.getSortedSections(status, data, sortBy, sortOrder); + const ListItem = SearchUtils.getListItem(type, status); + const data = SearchUtils.getSections(type, status, searchResults.data, searchResults.search); + const sortedData = SearchUtils.getSortedSections(type, status, data, sortBy, sortOrder); const sortedSelectedData = sortedData.map((item) => mapToItemWithSelectionInfo(item, selectedTransactions, canSelectMultiple)); const shouldShowEmptyState = !isDataLoaded || data.length === 0; @@ -236,13 +243,17 @@ function Search({queryJSON}: SearchProps) { <SearchStatusBar type={type} status={status} + resetOffset={resetOffset} /> <EmptySearchView type={type} /> </> ); } - const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => { + const toggleTransaction = (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { + if (SearchUtils.isReportActionListItemType(item)) { + return; + } if (SearchUtils.isTransactionListItemType(item)) { if (!item.keyForList) { return; @@ -269,7 +280,7 @@ function Search({queryJSON}: SearchProps) { }); }; - const openReport = (item: TransactionListItemType | ReportListItemType) => { + const openReport = (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { let reportID = SearchUtils.isTransactionListItemType(item) && !item.isFromOneTransactionReport ? item.transactionThreadReportID : item.reportID; if (!reportID) { @@ -282,6 +293,12 @@ function Search({queryJSON}: SearchProps) { SearchActions.createTransactionThread(hash, item.transactionID, reportID, item.moneyRequestReportActionID); } + if (SearchUtils.isReportActionListItemType(item)) { + const reportActionID = item.reportActionID; + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(reportID, reportActionID)); + return; + } + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(reportID)); }; @@ -332,10 +349,11 @@ function Search({queryJSON}: SearchProps) { <SearchStatusBar type={type} status={status} + resetOffset={resetOffset} /> - <SelectionListWithModal<ReportListItemType | TransactionListItemType> + <SelectionListWithModal<ReportListItemType | TransactionListItemType | ReportActionListItemType> sections={[{data: sortedSelectedData, isDisabled: false}]} - turnOnSelectionModeOnLongPress + turnOnSelectionModeOnLongPress={type !== CONST.SEARCH.DATA_TYPES.CHAT} onTurnOnSelectionMode={(item) => item && toggleTransaction(item)} onCheckboxPress={toggleTransaction} onSelectAll={toggleAllTransactions} @@ -352,7 +370,7 @@ function Search({queryJSON}: SearchProps) { /> ) } - canSelectMultiple={canSelectMultiple} + canSelectMultiple={type !== CONST.SEARCH.DATA_TYPES.CHAT && canSelectMultiple} customListHeaderHeight={searchHeaderHeight} // To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling, // we have configured a larger windowSize and a longer delay between batch renders. diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 9f2aca1ff957..195073f8b89f 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -28,7 +28,8 @@ type SearchColumnType = ValueOf<typeof CONST.SEARCH.TABLE_COLUMNS>; type ExpenseSearchStatus = ValueOf<typeof CONST.SEARCH.STATUS.EXPENSE>; type InvoiceSearchStatus = ValueOf<typeof CONST.SEARCH.STATUS.INVOICE>; type TripSearchStatus = ValueOf<typeof CONST.SEARCH.STATUS.TRIP>; -type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus; +type ChatSearchStatus = ValueOf<typeof CONST.SEARCH.STATUS.CHAT>; +type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus | ChatSearchStatus; type SearchContext = { currentSearchHash: number; @@ -41,7 +42,7 @@ type SearchContext = { type ASTNode = { operator: ValueOf<typeof CONST.SEARCH.SYNTAX_OPERATORS>; left: ValueOf<typeof CONST.SEARCH.SYNTAX_FILTER_KEYS> | ASTNode; - right: string | ASTNode; + right: string | ASTNode | string[]; }; type QueryFilter = { @@ -49,7 +50,7 @@ type QueryFilter = { value: string | number; }; -type AdvancedFiltersKeys = ValueOf<typeof CONST.SEARCH.SYNTAX_FILTER_KEYS> | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS; +type AdvancedFiltersKeys = ValueOf<typeof CONST.SEARCH.SYNTAX_FILTER_KEYS>; type QueryFilters = { [K in AdvancedFiltersKeys]?: QueryFilter[]; @@ -69,6 +70,7 @@ type SearchQueryAST = { type SearchQueryJSON = { inputQuery: SearchQueryString; hash: number; + flatFilters: QueryFilters; } & SearchQueryAST; export type { @@ -88,4 +90,5 @@ export type { ExpenseSearchStatus, InvoiceSearchStatus, TripSearchStatus, + ChatSearchStatus, }; diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index ffeeb12150b1..cc836a9e9e00 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -83,6 +83,7 @@ function BaseSelectionList<TItem extends ListItem>( listHeaderWrapperStyle, isRowMultilineSupported = false, isAlternateTextMultilineSupported = false, + alternateTextNumberOfLines = 2, textInputRef, headerMessageStyle, shouldHideListOnInitialRender = true, @@ -99,6 +100,7 @@ function BaseSelectionList<TItem extends ListItem>( shouldDelayFocus = true, shouldUpdateFocusedIndex = false, onLongPressRow, + shouldShowTextInput = !!textInputLabel || !!textInputIconLeft, shouldShowListEmptyContent = true, }: BaseSelectionListProps<TItem>, ref: ForwardedRef<SelectionListHandle>, @@ -108,7 +110,6 @@ function BaseSelectionList<TItem extends ListItem>( const listRef = useRef<RNSectionList<TItem, SectionWithIndexOffset<TItem>>>(null); const innerTextInputRef = useRef<RNTextInput | null>(null); const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null); - const shouldShowTextInput = !!textInputLabel || !!textInputIconLeft; const shouldShowSelectAll = !!onSelectAll; const activeElementRole = useActiveElementRole(); const isFocused = useIsFocused(); @@ -467,6 +468,7 @@ function BaseSelectionList<TItem extends ListItem>( keyForList={item.keyForList ?? ''} isMultilineSupported={isRowMultilineSupported} isAlternateTextMultilineSupported={isAlternateTextMultilineSupported} + alternateTextNumberOfLines={alternateTextNumberOfLines} onFocus={() => { if (isDisabled) { return; diff --git a/src/components/SelectionList/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx new file mode 100644 index 000000000000..8322c7e1406a --- /dev/null +++ b/src/components/SelectionList/ChatListItem.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import {View} from 'react-native'; +import {AttachmentContext} from '@components/AttachmentContext'; +import MultipleAvatars from '@components/MultipleAvatars'; +import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ReportActionItemDate from '@pages/home/report/ReportActionItemDate'; +import ReportActionItemFragment from '@pages/home/report/ReportActionItemFragment'; +import CONST from '@src/CONST'; +import BaseListItem from './BaseListItem'; +import type {ChatListItemProps, ListItem, ReportActionListItemType} from './types'; + +function ChatListItem<TItem extends ListItem>({ + item, + isFocused, + showTooltip, + isDisabled, + canSelectMultiple, + onSelectRow, + onDismissError, + onFocus, + onLongPressRow, + shouldSyncFocus, +}: ChatListItemProps<TItem>) { + const reportActionItem = item as unknown as ReportActionListItemType; + const from = reportActionItem.from; + const icons = [ + { + type: CONST.ICON_TYPE_AVATAR, + source: from.avatar, + name: reportActionItem.formattedFrom, + id: from.accountID, + }, + ]; + const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + + const attachmentContextValue = {type: CONST.ATTACHMENT_TYPE.SEARCH}; + + const contextValue = { + anchor: null, + report: undefined, + reportNameValuePairs: undefined, + action: undefined, + transactionThreadReport: undefined, + checkIfContextMenuActive: () => {}, + isDisabled: true, + }; + + const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; + const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; + + return ( + <BaseListItem + item={item} + pressableStyle={[ + [styles.selectionListPressableItemWrapper, styles.textAlignLeft, item.isSelected && styles.activeComponentBG, isFocused && styles.sidebarLinkActive, item.cursorStyle], + ]} + wrapperStyle={[styles.flexRow, styles.flex1, styles.justifyContentBetween, styles.userSelectNone]} + containerStyle={styles.mb3} + isFocused={isFocused} + isDisabled={isDisabled} + showTooltip={showTooltip} + canSelectMultiple={canSelectMultiple} + onLongPressRow={onLongPressRow} + onSelectRow={onSelectRow} + onDismissError={onDismissError} + errors={item.errors} + pendingAction={item.pendingAction} + keyForList={item.keyForList} + onFocus={onFocus} + shouldSyncFocus={shouldSyncFocus} + hoverStyle={item.isSelected && styles.activeComponentBG} + > + {(hovered) => ( + <ShowContextMenuContext.Provider value={contextValue}> + <AttachmentContext.Provider value={attachmentContextValue}> + <MultipleAvatars + icons={icons} + shouldShowTooltip={showTooltip} + secondAvatarStyle={[ + StyleUtils.getBackgroundAndBorderStyle(theme.sidebar), + isFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined, + hovered && !isFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, + ]} + /> + <View style={[styles.chatItemRight]}> + <View style={[styles.chatItemMessageHeader]}> + <View style={[styles.flexShrink1, styles.mr1]}> + <TextWithTooltip + shouldShowTooltip={showTooltip} + text={reportActionItem.formattedFrom} + style={[ + styles.chatItemMessageHeaderSender, + isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, + styles.sidebarLinkTextBold, + styles.pre, + ]} + /> + </View> + <ReportActionItemDate created={reportActionItem.created ?? ''} /> + </View> + <View style={styles.chatItemMessage}> + {reportActionItem.message.map((fragment, index) => ( + <ReportActionItemFragment + // eslint-disable-next-line react/no-array-index-key + key={`actionFragment-${reportActionItem.reportActionID}-${index}`} + fragment={fragment} + actionName={reportActionItem.actionName} + source="" + accountID={from.accountID} + isFragmentContainingDisplayName={index === 0} + /> + ))} + </View> + </View> + </AttachmentContext.Provider> + </ShowContextMenuContext.Provider> + )} + </BaseListItem> + ); +} + +ChatListItem.displayName = 'ChatListItem'; + +export default ChatListItem; diff --git a/src/components/SelectionList/RadioListItem.tsx b/src/components/SelectionList/RadioListItem.tsx index f2df90592618..067c2341ef67 100644 --- a/src/components/SelectionList/RadioListItem.tsx +++ b/src/components/SelectionList/RadioListItem.tsx @@ -18,6 +18,7 @@ function RadioListItem<TItem extends ListItem>({ rightHandSideComponent, isMultilineSupported = false, isAlternateTextMultilineSupported = false, + alternateTextNumberOfLines = 2, onFocus, shouldSyncFocus, }: RadioListItemProps<TItem>) { @@ -71,7 +72,7 @@ function RadioListItem<TItem extends ListItem>({ isAlternateTextMultilineSupported ? styles.preWrap : styles.pre, isAlternateTextMultilineSupported ? {maxWidth: alternateTextMaxWidth} : null, ]} - numberOfLines={isAlternateTextMultilineSupported ? 2 : 1} + numberOfLines={isAlternateTextMultilineSupported ? alternateTextNumberOfLines : 1} /> )} </View> diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index 92666a8081d6..38420c47a8fa 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -1,3 +1,4 @@ +import {Str} from 'expensify-common'; import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; @@ -15,7 +16,9 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; +import {getFileName} from '@libs/fileDownload/FileUtils'; import Parser from '@libs/Parser'; +import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import StringUtils from '@libs/StringUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; @@ -81,6 +84,11 @@ function ReceiptCell({transactionItem}: TransactionCellProps) { const isViewAction = transactionItem.action === CONST.SEARCH.ACTION_TYPES.VIEW; const canModifyReceipt = isViewAction && transactionItem.canDelete; + const filename = getFileName(transactionItem?.receipt?.source ?? ''); + const receiptURIs = getThumbnailAndImageURIs(transactionItem, null, filename); + const isReceiptPDF = Str.isPDF(filename); + const source = tryResolveUrlFromApiRoot(isReceiptPDF && !receiptURIs.isLocalFile ? receiptURIs.thumbnail ?? '' : receiptURIs.image ?? ''); + return ( <View style={[ @@ -91,7 +99,7 @@ function ReceiptCell({transactionItem}: TransactionCellProps) { ]} > <ReceiptImage - source={tryResolveUrlFromApiRoot(transactionItem?.receipt?.source ?? '')} + source={source} isEReceipt={transactionItem.hasEReceipt} transactionID={transactionItem.transactionID} shouldUseThumbnailImage={!transactionItem?.receipt?.source} diff --git a/src/components/SelectionList/SearchTableHeader.tsx b/src/components/SelectionList/SearchTableHeader.tsx index cb1914824a20..f54532a7f318 100644 --- a/src/components/SelectionList/SearchTableHeader.tsx +++ b/src/components/SelectionList/SearchTableHeader.tsx @@ -85,6 +85,13 @@ const expenseHeaders: SearchColumnConfig[] = [ }, ]; +const SearchColumns = { + [CONST.SEARCH.DATA_TYPES.EXPENSE]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.INVOICE]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.TRIP]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.CHAT]: null, +}; + type SearchTableHeaderProps = { data: OnyxTypes.SearchResults['data']; metadata: OnyxTypes.SearchResults['search']; @@ -102,6 +109,10 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, onSortPress, shou const {translate} = useLocalize(); const displayNarrowVersion = isMediumScreenWidth || isSmallScreenWidth; + if (SearchColumns[metadata.type] === null) { + return; + } + if (displayNarrowVersion) { return; } @@ -109,7 +120,7 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, onSortPress, shou return ( <View style={[styles.flex1]}> <View style={[styles.flex1, styles.flexRow, styles.gap3, styles.pl4]}> - {expenseHeaders.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { + {SearchColumns[metadata.type]?.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { if (!shouldShow(data, metadata)) { return null; } diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index c484a59fee78..7bdbb03f2101 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -5,10 +5,11 @@ import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; import type CursorStyles from '@styles/utils/cursor/types'; import type CONST from '@src/CONST'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {SearchPersonalDetails, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; +import type {SearchPersonalDetails, SearchReport, SearchReportAction, SearchTransaction} from '@src/types/onyx/SearchResults'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type IconAsset from '@src/types/utils/IconAsset'; +import type ChatListItem from './ChatListItem'; import type InviteMemberListItem from './InviteMemberListItem'; import type RadioListItem from './RadioListItem'; import type ReportListItem from './Search/ReportListItem'; @@ -61,6 +62,9 @@ type CommonListItemProps<TItem extends ListItem> = { /** Whether to wrap the alternate text up to 2 lines */ isAlternateTextMultilineSupported?: boolean; + /** Number of lines to show for alternate text */ + alternateTextNumberOfLines?: number; + /** Handles what to do when the item is focused */ onFocus?: () => void; @@ -206,6 +210,21 @@ type TransactionListItemType = ListItem & keyForList: string; }; +type ReportActionListItemType = ListItem & + SearchReportAction & { + /** The personal details of the user posting comment */ + from: SearchPersonalDetails; + + /** final and formatted "from" value used for displaying and sorting */ + formattedFrom: string; + + /** final "date" value used for sorting */ + date: string; + + /** Key used internally by React */ + keyForList: string; + }; + type ReportListItemType = ListItem & SearchReport & { /** The personal details of the user requesting money */ @@ -277,7 +296,16 @@ type TransactionListItemProps<TItem extends ListItem> = ListItemProps<TItem>; type ReportListItemProps<TItem extends ListItem> = ListItemProps<TItem>; -type ValidListItem = typeof RadioListItem | typeof UserListItem | typeof TableListItem | typeof InviteMemberListItem | typeof TransactionListItem | typeof ReportListItem; +type ChatListItemProps<TItem extends ListItem> = ListItemProps<TItem>; + +type ValidListItem = + | typeof RadioListItem + | typeof UserListItem + | typeof TableListItem + | typeof InviteMemberListItem + | typeof TransactionListItem + | typeof ReportListItem + | typeof ChatListItem; type Section<TItem extends ListItem> = { /** Title of the section */ @@ -338,6 +366,9 @@ type BaseSelectionListProps<TItem extends ListItem> = Partial<ChildrenProps> & { /** Callback to fire when an error is dismissed */ onDismissError?: (item: TItem) => void; + /** Whether to show the text input */ + shouldShowTextInput?: boolean; + /** Label for the text input */ textInputLabel?: string; @@ -461,6 +492,9 @@ type BaseSelectionListProps<TItem extends ListItem> = Partial<ChildrenProps> & { /** Whether to wrap the alternate text up to 2 lines */ isAlternateTextMultilineSupported?: boolean; + /** Number of lines to show for alternate text */ + alternateTextNumberOfLines?: number; + /** Ref for textInput */ textInputRef?: MutableRefObject<TextInput | null> | ((ref: TextInput | null) => void); @@ -556,4 +590,6 @@ export type { TransactionListItemType, UserListItemProps, ValidListItem, + ReportActionListItemType, + ChatListItemProps, }; diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx index ca34e579a431..2d218bc815fe 100644 --- a/src/components/SelectionListWithModal/index.tsx +++ b/src/components/SelectionListWithModal/index.tsx @@ -23,13 +23,15 @@ function SelectionListWithModal<TItem extends ListItem>( const [isModalVisible, setIsModalVisible] = useState(false); const [longPressedItem, setLongPressedItem] = useState<TItem | null>(null); const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout here because there is a race condition that causes shouldUseNarrowLayout to change indefinitely in this component + // See https://github.com/Expensify/App/issues/48675 for more details + const {isSmallScreenWidth} = useResponsiveLayout(); const {selectionMode} = useMobileSelectionMode(true); useEffect(() => { // We can access 0 index safely as we are not displaying multiple sections in table view const selectedItems = sections[0].data.filter((item) => item.isSelected); - if (!shouldUseNarrowLayout) { + if (!isSmallScreenWidth) { if (selectedItems.length === 0) { turnOffMobileSelectionMode(); } @@ -38,11 +40,11 @@ function SelectionListWithModal<TItem extends ListItem>( if (selectedItems.length > 0 && !selectionMode?.isEnabled) { turnOnMobileSelectionMode(); } - }, [sections, selectionMode, shouldUseNarrowLayout]); + }, [sections, selectionMode, isSmallScreenWidth]); const handleLongPressRow = (item: TItem) => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (!turnOnSelectionModeOnLongPress || !shouldUseNarrowLayout || item?.isDisabled || item?.isDisabledCheckbox) { + if (!turnOnSelectionModeOnLongPress || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox) { return; } setLongPressedItem(item); diff --git a/src/components/SelectionScreen.tsx b/src/components/SelectionScreen.tsx index 8f6ccbf64c81..b86084421fac 100644 --- a/src/components/SelectionScreen.tsx +++ b/src/components/SelectionScreen.tsx @@ -145,6 +145,7 @@ function SelectionScreen<T = string>({ pendingAction={pendingAction} style={[styles.flex1]} contentContainerStyle={[styles.flex1]} + shouldDisableOpacity={!sections.length} > <SelectionList onSelectRow={onSelectRow} diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index fc72f2fe7418..2e0a7c927f4f 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -5,13 +5,11 @@ import {useOnyx, withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import Navigation from '@libs/Navigation/Navigation'; -import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as BankAccounts from '@userActions/BankAccounts'; import * as IOU from '@userActions/IOU'; -import * as PolicyActions from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -105,6 +103,9 @@ type SettlementButtonProps = SettlementButtonOnyxProps & { /** The anchor alignment of the popover menu for KYC wall popover */ kycWallAnchorAlignment?: AnchorAlignment; + /** Whether the personal bank account option should be shown */ + shouldShowPersonalBankAccountOption?: boolean; + /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */ enterKeyEventListenerPriority?: number; @@ -144,6 +145,7 @@ function SettlementButton({ shouldShowApproveButton = false, shouldDisableApproveButton = false, style, + shouldShowPersonalBankAccountOption = false, enterKeyEventListenerPriority = 0, confirmApproval, policy, @@ -153,10 +155,7 @@ function SettlementButton({ }: SettlementButtonProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const session = useSession(); - const primaryPolicy = useMemo(() => PolicyActions.getPrimaryPolicy(activePolicyID, session?.email), [activePolicyID, session?.email]); - // The app would crash due to subscribing to the entire report collection if chatReportID is an empty string. So we should have a fallback ID here. const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID || -1}`); const isInvoiceReport = (!isEmptyObject(iouReport) && ReportUtils.isInvoiceReport(iouReport)) || false; @@ -166,35 +165,25 @@ function SettlementButton({ (!shouldHidePaymentOptions && ReportUtils.isPayer(session, iouReport) && policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL); const shouldShowPayElsewhereOption = (!isPaidGroupPolicy || policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL) && !isInvoiceReport; const paymentButtonOptions = useMemo(() => { + const buttonOptions = []; const isExpenseReport = ReportUtils.isExpenseReport(iouReport); const paymentMethods = { + [CONST.IOU.PAYMENT_TYPE.EXPENSIFY]: { + text: translate('iou.settleExpensify', {formattedAmount}), + icon: Expensicons.Wallet, + value: CONST.IOU.PAYMENT_TYPE.EXPENSIFY, + }, [CONST.IOU.PAYMENT_TYPE.VBBA]: { text: translate('iou.settleExpensify', {formattedAmount}), icon: Expensicons.Wallet, value: CONST.IOU.PAYMENT_TYPE.VBBA, }, - [CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT]: { - text: translate('iou.settlePersonalBank', {formattedAmount}), - icon: Expensicons.Bank, - value: CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT, - }, - [CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT]: { - text: translate('iou.settleBusinessBank', {formattedAmount}), - icon: Expensicons.Bank, - value: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, - }, - [CONST.PAYMENT_METHODS.DEBIT_CARD]: { - text: translate('iou.settleDebitCard', {formattedAmount}), - icon: Expensicons.CreditCard, - value: CONST.PAYMENT_METHODS.DEBIT_CARD, - }, [CONST.IOU.PAYMENT_TYPE.ELSEWHERE]: { text: translate('iou.payElsewhere', {formattedAmount}), icon: Expensicons.Cash, value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, }, }; - const buttonOptions = []; const approveButtonOption = { text: translate('iou.approve'), icon: Expensicons.ThumbsUp, @@ -212,10 +201,12 @@ function SettlementButton({ // If the user has previously chosen a specific payment option or paid for some expense, // let's use the last payment method or use default. const paymentMethod = nvpLastPaymentMethod?.[policyID] ?? '-1'; - if (canUseWallet || (isExpenseReport && shouldShowPaywithExpensifyOption)) { - buttonOptions.push(paymentMethods[CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT]); + if (canUseWallet) { + buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.EXPENSIFY]); + } + if (isExpenseReport && shouldShowPaywithExpensifyOption) { + buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.VBBA]); } - if (shouldShowPayElsewhereOption) { buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]); } @@ -238,22 +229,20 @@ function SettlementButton({ }); } - if (PolicyUtils.isPolicyAdmin(primaryPolicy) && PolicyUtils.isPaidGroupPolicy(primaryPolicy)) { - buttonOptions.push({ - text: translate('iou.settleBusiness', {formattedAmount}), - icon: Expensicons.Building, - value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - backButtonText: translate('iou.business'), - subMenuItems: [ - { - text: translate('iou.payElsewhere', {formattedAmount: ''}), - icon: Expensicons.Cash, - value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - onSelected: () => onPress(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, true), - }, - ], - }); - } + buttonOptions.push({ + text: translate('iou.settleBusiness', {formattedAmount}), + icon: Expensicons.Building, + value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + backButtonText: translate('iou.business'), + subMenuItems: [ + { + text: translate('iou.payElsewhere', {formattedAmount: ''}), + icon: Expensicons.Cash, + value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + onSelected: () => onPress(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, true), + }, + ], + }); } if (shouldShowApproveButton) { @@ -275,12 +264,7 @@ function SettlementButton({ return; } - if ( - iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA || - iouPaymentType === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT || - iouPaymentType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT || - iouPaymentType === CONST.PAYMENT_METHODS.DEBIT_CARD - ) { + if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { triggerKYCFlow(event, iouPaymentType); BankAccounts.setPersonalBankAccountContinueKYCOnSuccess(ROUTES.ENABLE_PAYMENTS); return; @@ -314,6 +298,7 @@ function SettlementButton({ chatReportID={chatReportID} iouReport={iouReport} anchorAlignment={kycWallAnchorAlignment} + shouldShowPersonalBankAccountOption={shouldShowPersonalBankAccountOption} > {(triggerKYCFlow, buttonRef) => ( <ButtonWithDropdownMenu<PaymentType> @@ -321,7 +306,10 @@ function SettlementButton({ onOptionsMenuShow={onPaymentOptionsShow} onOptionsMenuHide={onPaymentOptionsHide} buttonRef={buttonRef} + shouldAlwaysShowDropdownMenu={isInvoiceReport} + customText={isInvoiceReport ? translate('iou.settlePayment', {formattedAmount}) : undefined} menuHeaderText={isInvoiceReport ? translate('workspace.invoices.paymentMethods.chooseInvoiceMethod') : undefined} + isSplitButton={!isInvoiceReport} isDisabled={isDisabled} isLoading={isLoading} onPress={(event, iouPaymentType) => selectPaymentType(event, iouPaymentType, triggerKYCFlow)} diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts index b3050f986be1..6fefa987fac3 100644 --- a/src/components/ShowContextMenuContext.ts +++ b/src/components/ShowContextMenuContext.ts @@ -16,6 +16,7 @@ type ShowContextMenuContextProps = { action: OnyxEntry<ReportAction>; transactionThreadReport?: OnyxEntry<Report>; checkIfContextMenuActive: () => void; + isDisabled: boolean; }; const ShowContextMenuContext = createContext<ShowContextMenuContextProps>({ @@ -25,6 +26,7 @@ const ShowContextMenuContext = createContext<ShowContextMenuContextProps>({ action: undefined, transactionThreadReport: undefined, checkIfContextMenuActive: () => {}, + isDisabled: false, }); ShowContextMenuContext.displayName = 'ShowContextMenuContext'; diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx index 4117046d86ef..274813d9a44b 100644 --- a/src/components/TabSelector/TabSelectorItem.tsx +++ b/src/components/TabSelector/TabSelectorItem.tsx @@ -39,12 +39,13 @@ function TabSelectorItem({icon, title = '', onPress = () => {}, backgroundColor return ( <AnimatedPressableWithFeedback accessibilityLabel={title} - style={[styles.tabSelectorButton, styles.tabBackground(isHovered, isActive, backgroundColor)]} + style={[styles.tabSelectorButton, styles.tabBackground(isHovered, isActive, backgroundColor), styles.userSelectNone]} wrapperStyle={[styles.flex1]} onPress={onPress} onHoverIn={() => setIsHovered(true)} onHoverOut={() => setIsHovered(false)} role={CONST.ROLE.BUTTON} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > <TabIcon icon={icon} diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index a20dc353394e..fe6a2e86bc00 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -1,4 +1,5 @@ import React, {useEffect, useMemo} from 'react'; +import useDebouncedState from '@hooks/useDebouncedState'; import useThemePreferenceWithStaticOverride from '@hooks/useThemePreferenceWithStaticOverride'; import DomUtils from '@libs/DomUtils'; // eslint-disable-next-line no-restricted-imports @@ -12,8 +13,13 @@ type ThemeProviderProps = React.PropsWithChildren & { function ThemeProvider({children, theme: staticThemePreference}: ThemeProviderProps) { const themePreference = useThemePreferenceWithStaticOverride(staticThemePreference); + const [, debouncedTheme, setDebouncedTheme] = useDebouncedState(themePreference); - const theme = useMemo(() => themes[themePreference], [themePreference]); + useEffect(() => { + setDebouncedTheme(themePreference); + }, [setDebouncedTheme, themePreference]); + + const theme = useMemo(() => themes[debouncedTheme], [debouncedTheme]); useEffect(() => { DomUtils.addCSS(DomUtils.getAutofilledInputStyle(theme.text), 'autofill-input'); diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx index 9ce1dd813825..d0ff254324ae 100644 --- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -1,9 +1,8 @@ -import React, {memo, useEffect, useRef} from 'react'; -import {InteractionManager} from 'react-native'; +import React, {memo, useEffect, useRef, useState} from 'react'; import type {LayoutRectangle, NativeSyntheticEvent} from 'react-native'; import GenericTooltip from '@components/Tooltip/GenericTooltip'; import type {EducationalTooltipProps} from '@components/Tooltip/types'; -import CONST from '@src/CONST'; +import measureTooltipCoordinate from './measureTooltipCoordinate'; type LayoutChangeEventWithTarget = NativeSyntheticEvent<{layout: LayoutRectangle; target: HTMLElement}>; @@ -11,9 +10,12 @@ type LayoutChangeEventWithTarget = NativeSyntheticEvent<{layout: LayoutRectangle * A component used to wrap an element intended for displaying a tooltip. * This tooltip would show immediately without user's interaction and hide after 5 seconds. */ -function BaseEducationalTooltip({children, shouldAutoDismiss = false, ...props}: EducationalTooltipProps) { +function BaseEducationalTooltip({children, shouldAutoDismiss = false, shouldRender = false, ...props}: EducationalTooltipProps) { const hideTooltipRef = useRef<() => void>(); + const [shouldMeasure, setShouldMeasure] = useState(false); + const show = useRef<() => void>(); + useEffect( () => () => { if (!hideTooltipRef.current) { @@ -37,6 +39,16 @@ function BaseEducationalTooltip({children, shouldAutoDismiss = false, ...props}: }; }, [shouldAutoDismiss]); + useEffect(() => { + if (!shouldRender || !shouldMeasure) { + return; + } + // When tooltip is used inside an animated view (e.g. popover), we need to wait for the animation to finish before measuring content. + setTimeout(() => { + show.current?.(); + }, 500); + }, [shouldMeasure, shouldRender]); + return ( <GenericTooltip shouldForceAnimate @@ -48,22 +60,12 @@ function BaseEducationalTooltip({children, shouldAutoDismiss = false, ...props}: hideTooltipRef.current = hideTooltip; return React.cloneElement(children as React.ReactElement, { onLayout: (e: LayoutChangeEventWithTarget) => { + if (!shouldMeasure) { + setShouldMeasure(true); + } // e.target is specific to native, use e.nativeEvent.target on web instead const target = e.target || e.nativeEvent.target; - // When tooltip is used inside an animated view (e.g. popover), we need to wait for the animation to finish before measuring content. - setTimeout(() => { - InteractionManager.runAfterInteractions(() => { - target?.measure((fx, fy, width, height, px, py) => { - updateTargetBounds({ - height, - width, - x: px, - y: py, - }); - showTooltip(); - }); - }); - }, CONST.ANIMATED_TRANSITION); + show.current = () => measureTooltipCoordinate(target, updateTargetBounds, showTooltip); }, }); }} diff --git a/src/components/Tooltip/EducationalTooltip/index.tsx b/src/components/Tooltip/EducationalTooltip/index.tsx index d43ff64d7e8e..03500f768dd9 100644 --- a/src/components/Tooltip/EducationalTooltip/index.tsx +++ b/src/components/Tooltip/EducationalTooltip/index.tsx @@ -2,11 +2,7 @@ import React from 'react'; import type {TooltipExtendedProps} from '@components/Tooltip/types'; import BaseEducationalTooltip from './BaseEducationalTooltip'; -function EducationalTooltip({shouldRender = true, children, ...props}: TooltipExtendedProps) { - if (!shouldRender) { - return children; - } - +function EducationalTooltip({children, ...props}: TooltipExtendedProps) { return ( <BaseEducationalTooltip // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts new file mode 100644 index 000000000000..5cc2ab8a74a7 --- /dev/null +++ b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts @@ -0,0 +1,9 @@ +import type React from 'react'; +import type {LayoutRectangle, NativeMethods} from 'react-native'; + +export default function measureTooltipCoordinate(target: React.Component & Readonly<NativeMethods>, updateTargetBounds: (rect: LayoutRectangle) => void, showTooltip: () => void) { + return target?.measure((x, y, width, height, px, py) => { + updateTargetBounds({height, width, x: px, y: py}); + showTooltip(); + }); +} diff --git a/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts new file mode 100644 index 000000000000..72cc75115e21 --- /dev/null +++ b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts @@ -0,0 +1,9 @@ +import type React from 'react'; +import type {LayoutRectangle, NativeMethods} from 'react-native'; + +export default function measureTooltipCoordinate(target: React.Component & Readonly<NativeMethods>, updateTargetBounds: (rect: LayoutRectangle) => void, showTooltip: () => void) { + return target?.measureInWindow((x, y, width, height) => { + updateTargetBounds({height, width, x, y}); + showTooltip(); + }); +} diff --git a/src/components/Tooltip/types.ts b/src/components/Tooltip/types.ts index 4165b960f322..0462b36fa524 100644 --- a/src/components/Tooltip/types.ts +++ b/src/components/Tooltip/types.ts @@ -75,6 +75,9 @@ type EducationalTooltipProps = ChildrenProps & SharedTooltipProps & { /** Whether to automatically dismiss the tooltip after 5 seconds */ shouldAutoDismiss?: boolean; + + /** Whether the actual Tooltip should be rendered. If false, it's just going to return the children */ + shouldRender?: boolean; }; type TooltipExtendedProps = (EducationalTooltipProps | TooltipProps) & { diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx new file mode 100644 index 000000000000..247c0c606901 --- /dev/null +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -0,0 +1,257 @@ +import {useFocusEffect} from '@react-navigation/native'; +import type {ForwardedRef} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import MagicCodeInput from '@components/MagicCodeInput'; +import type {AutoCompleteVariant, MagicCodeInputHandle} from '@components/MagicCodeInput'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import * as User from '@userActions/User'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Account, ValidateMagicCodeAction} from '@src/types/onyx'; +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type ValidateCodeFormHandle = { + focus: () => void; + focusLastSelected: () => void; +}; + +type ValidateCodeFormError = { + validateCode?: TranslationPaths; +}; + +type BaseValidateCodeFormOnyxProps = { + /** The details about the account that the user is signing in with */ + account: OnyxEntry<Account>; +}; + +type ValidateCodeFormProps = { + /** If the magic code has been resent previously */ + hasMagicCodeBeenSent?: boolean; + + /** Specifies autocomplete hints for the system, so it can provide autofill */ + autoComplete?: AutoCompleteVariant; + + /** Forwarded inner ref */ + innerRef?: ForwardedRef<ValidateCodeFormHandle>; + + /** The state of magic code that being sent */ + validateCodeAction?: ValidateMagicCodeAction; + + /** The pending action for submitting form */ + validatePendingAction?: PendingAction | null; + + /** The error of submitting */ + validateError?: Errors; + + /** Function is called when submitting form */ + handleSubmitForm: (validateCode: string) => void; + + /** Function to clear error of the form */ + clearError: () => void; +}; + +type BaseValidateCodeFormProps = BaseValidateCodeFormOnyxProps & ValidateCodeFormProps; + +function BaseValidateCodeForm({ + account = {}, + hasMagicCodeBeenSent, + autoComplete = 'one-time-code', + innerRef = () => {}, + validateCodeAction, + validatePendingAction, + validateError, + handleSubmitForm, + clearError, +}: BaseValidateCodeFormProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const [formError, setFormError] = useState<ValidateCodeFormError>({}); + const [validateCode, setValidateCode] = useState(''); + const inputValidateCodeRef = useRef<MagicCodeInputHandle>(null); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case + const shouldDisableResendValidateCode = !!isOffline || account?.isLoading; + const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null); + + useImperativeHandle(innerRef, () => ({ + focus() { + inputValidateCodeRef.current?.focus(); + }, + focusLastSelected() { + if (!inputValidateCodeRef.current) { + return; + } + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + focusTimeoutRef.current = setTimeout(() => { + inputValidateCodeRef.current?.focusLastSelected(); + }, CONST.ANIMATED_TRANSITION); + }, + })); + + useFocusEffect( + useCallback(() => { + if (!inputValidateCodeRef.current) { + return; + } + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + focusTimeoutRef.current = setTimeout(() => { + inputValidateCodeRef.current?.focusLastSelected(); + }, CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, []), + ); + + useEffect(() => { + if (!validateError) { + return; + } + clearError(); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [clearError, validateError]); + + useEffect(() => { + if (!hasMagicCodeBeenSent) { + return; + } + inputValidateCodeRef.current?.clear(); + }, [hasMagicCodeBeenSent]); + + /** + * Request a validate code / magic code be sent to verify this contact method + */ + const resendValidateCode = () => { + User.requestValidateCodeAction(); + inputValidateCodeRef.current?.clear(); + }; + + /** + * Handle text input and clear formError upon text change + */ + const onTextInput = useCallback( + (text: string) => { + setValidateCode(text); + setFormError({}); + + if (validateError) { + clearError(); + User.clearValidateCodeActionError('actionVerified'); + } + }, + [validateError, clearError], + ); + + /** + * Check that all the form fields are valid, then trigger the submit callback + */ + const validateAndSubmitForm = useCallback(() => { + if (!validateCode.trim()) { + setFormError({validateCode: 'validateCodeForm.error.pleaseFillMagicCode'}); + return; + } + + if (!ValidationUtils.isValidValidateCode(validateCode)) { + setFormError({validateCode: 'validateCodeForm.error.incorrectMagicCode'}); + return; + } + + setFormError({}); + handleSubmitForm(validateCode); + }, [validateCode, handleSubmitForm]); + + return ( + <> + <MagicCodeInput + autoComplete={autoComplete} + ref={inputValidateCodeRef} + name="validateCode" + value={validateCode} + onChangeText={onTextInput} + errorText={formError?.validateCode ? translate(formError?.validateCode) : ErrorUtils.getLatestErrorMessage(account ?? {})} + hasError={!isEmptyObject(validateError)} + onFulfill={validateAndSubmitForm} + autoFocus={false} + /> + <OfflineWithFeedback + pendingAction={validateCodeAction?.pendingFields?.validateCodeSent} + errors={ErrorUtils.getLatestErrorField(validateCodeAction, 'actionVerified')} + errorRowStyles={[styles.mt2]} + onClose={() => User.clearValidateCodeActionError('actionVerified')} + > + <View style={[styles.mt2, styles.dFlex, styles.flexColumn, styles.alignItemsStart]}> + <PressableWithFeedback + disabled={shouldDisableResendValidateCode} + style={[styles.mr1]} + onPress={resendValidateCode} + underlayColor={theme.componentBG} + hoverDimmingValue={1} + pressDimmingValue={0.2} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('validateCodeForm.magicCodeNotReceived')} + > + <Text style={[StyleUtils.getDisabledLinkStyles(shouldDisableResendValidateCode)]}>{translate('validateCodeForm.magicCodeNotReceived')}</Text> + </PressableWithFeedback> + {hasMagicCodeBeenSent && ( + <DotIndicatorMessage + type="success" + style={[styles.mt6, styles.flex0]} + // eslint-disable-next-line @typescript-eslint/naming-convention + messages={{0: translate('validateCodeModal.successfulNewCodeRequest')}} + /> + )} + </View> + </OfflineWithFeedback> + <OfflineWithFeedback + pendingAction={validatePendingAction} + errors={validateError} + errorRowStyles={[styles.mt2]} + onClose={() => clearError()} + > + <Button + isDisabled={isOffline} + text={translate('common.verify')} + onPress={validateAndSubmitForm} + style={[styles.mt4]} + success + pressOnEnter + large + isLoading={account?.isLoading} + /> + </OfflineWithFeedback> + </> + ); +} + +BaseValidateCodeForm.displayName = 'BaseValidateCodeForm'; + +export type {ValidateCodeFormProps, ValidateCodeFormHandle}; + +export default withOnyx<BaseValidateCodeFormProps, BaseValidateCodeFormOnyxProps>({ + account: {key: ONYXKEYS.ACCOUNT}, +})(BaseValidateCodeForm); diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx new file mode 100644 index 000000000000..704405f93a2c --- /dev/null +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx @@ -0,0 +1,14 @@ +import React, {forwardRef} from 'react'; +import BaseValidateCodeForm from './BaseValidateCodeForm'; +import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm'; + +const ValidateCodeForm = forwardRef<ValidateCodeFormHandle, ValidateCodeFormProps>((props, ref) => ( + <BaseValidateCodeForm + autoComplete="sms-otp" + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + innerRef={ref} + /> +)); + +export default ValidateCodeForm; diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/index.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/index.tsx new file mode 100644 index 000000000000..453fc9c3f373 --- /dev/null +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/index.tsx @@ -0,0 +1,14 @@ +import React, {forwardRef} from 'react'; +import BaseValidateCodeForm from './BaseValidateCodeForm'; +import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm'; + +const ValidateCodeForm = forwardRef<ValidateCodeFormHandle, ValidateCodeFormProps>((props, ref) => ( + <BaseValidateCodeForm + autoComplete="one-time-code" + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + innerRef={ref} + /> +)); + +export default ValidateCodeForm; diff --git a/src/components/ValidateCodeActionModal/index.tsx b/src/components/ValidateCodeActionModal/index.tsx new file mode 100644 index 000000000000..63528f1d5cf9 --- /dev/null +++ b/src/components/ValidateCodeActionModal/index.tsx @@ -0,0 +1,75 @@ +import React, {useCallback, useEffect, useRef} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Modal from '@components/Modal'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as User from '@libs/actions/User'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ValidateCodeActionModalProps} from './type'; +import ValidateCodeForm from './ValidateCodeForm'; +import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; + +function ValidateCodeActionModal({isVisible, title, description, onClose, validatePendingAction, validateError, handleSubmitForm, clearError}: ValidateCodeActionModalProps) { + const themeStyles = useThemeStyles(); + const firstRenderRef = useRef(true); + const validateCodeFormRef = useRef<ValidateCodeFormHandle>(null); + + const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE); + + const hide = useCallback(() => { + clearError(); + onClose(); + }, [onClose, clearError]); + + useEffect(() => { + if (!firstRenderRef.current || !isVisible) { + return; + } + firstRenderRef.current = false; + User.requestValidateCodeAction(); + }, [isVisible]); + + return ( + <Modal + type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED} + isVisible={isVisible} + onClose={hide} + onModalHide={hide} + hideModalContentWhileAnimating + useNativeDriver + shouldUseModalPaddingStyle={false} + > + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + shouldEnableMaxHeight + testID={ValidateCodeActionModal.displayName} + offlineIndicatorStyle={themeStyles.mtAuto} + > + <HeaderWithBackButton + title={title} + onBackButtonPress={hide} + /> + + <View style={[themeStyles.ph5, themeStyles.mt3, themeStyles.mb7]}> + <Text style={[themeStyles.mb3]}>{description}</Text> + <ValidateCodeForm + validateCodeAction={validateCodeAction} + validatePendingAction={validatePendingAction} + validateError={validateError} + handleSubmitForm={handleSubmitForm} + clearError={clearError} + ref={validateCodeFormRef} + /> + </View> + </ScreenWrapper> + </Modal> + ); +} + +ValidateCodeActionModal.displayName = 'ValidateCodeActionModal'; + +export default ValidateCodeActionModal; diff --git a/src/components/ValidateCodeActionModal/type.ts b/src/components/ValidateCodeActionModal/type.ts new file mode 100644 index 000000000000..3cbfe62513d1 --- /dev/null +++ b/src/components/ValidateCodeActionModal/type.ts @@ -0,0 +1,30 @@ +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; + +type ValidateCodeActionModalProps = { + /** Whether the modal is visible */ + isVisible: boolean; + + /** Title of the modal */ + title: string; + + /** Description of the modal */ + description: string; + + /** Function to call when the user closes the modal */ + onClose: () => void; + + /** The pending action for submitting form */ + validatePendingAction?: PendingAction | null; + + /** The error of submitting */ + validateError?: Errors; + + /** Function is called when submitting form */ + handleSubmitForm: (validateCode: string) => void; + + /** Function to clear error of the form */ + clearError: () => void; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {ValidateCodeActionModalProps}; diff --git a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx index 48465e208b70..9615f7d9de4d 100644 --- a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx +++ b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx @@ -40,7 +40,7 @@ function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel}: Video </View> )} <ShowContextMenuContext.Consumer> - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( <PressableWithoutFeedback style={[styles.videoThumbnailContainer]} accessibilityLabel={accessibilityLabel} @@ -48,9 +48,12 @@ function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel}: Video onPress={onPress} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)) - } + onLongPress={(event) => { + if (isDisabled) { + return; + } + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); + }} shouldUseHapticsOnLongPress > <View style={[styles.videoThumbnailPlayButton]}> diff --git a/src/components/WorkspaceMembersSelectionList.tsx b/src/components/WorkspaceMembersSelectionList.tsx new file mode 100644 index 000000000000..99f949903a2b --- /dev/null +++ b/src/components/WorkspaceMembersSelectionList.tsx @@ -0,0 +1,115 @@ +import React, {useMemo} from 'react'; +import type {SectionListData} from 'react-native'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; +import Badge from './Badge'; +import {FallbackAvatar} from './Icon/Expensicons'; +import {usePersonalDetails} from './OnyxProvider'; +import SelectionList from './SelectionList'; +import InviteMemberListItem from './SelectionList/InviteMemberListItem'; +import type {Section} from './SelectionList/types'; + +type SelectionListApprover = { + text: string; + alternateText: string; + keyForList: string; + isSelected: boolean; + login: string; + rightElement?: React.ReactNode; + icons: Icon[]; +}; +type ApproverSection = SectionListData<SelectionListApprover, Section<SelectionListApprover>>; + +type WorkspaceMembersSelectionListProps = { + policyID: string; + selectedApprover: string; + setApprover: (email: string) => void; +}; + +function WorkspaceMembersSelectionList({policyID, selectedApprover, setApprover}: WorkspaceMembersSelectionListProps) { + const {translate} = useLocalize(); + const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const personalDetails = usePersonalDetails(); + const policy = usePolicy(policyID); + + const sections: ApproverSection[] = useMemo(() => { + const approvers: SelectionListApprover[] = []; + + if (policy?.employeeList) { + const availableApprovers = Object.values(policy.employeeList) + .map((employee): SelectionListApprover | null => { + const isAdmin = employee?.role === CONST.REPORT.ROLE.ADMIN; + const email = employee.email; + + if (!email) { + return null; + } + + const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList); + const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? ''); + const {avatar, displayName = email} = personalDetails?.[accountID] ?? {}; + + return { + text: displayName, + alternateText: email, + keyForList: email, + isSelected: selectedApprover === email, + login: email, + icons: [{source: avatar ?? FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: displayName, id: accountID}], + rightElement: isAdmin ? <Badge text={translate('common.admin')} /> : undefined, + }; + }) + .filter((approver): approver is SelectionListApprover => !!approver); + + approvers.push(...availableApprovers); + } + + const filteredApprovers = + debouncedSearchTerm !== '' + ? approvers.filter((option) => { + const searchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(debouncedSearchTerm); + const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); + return isPartOfSearchTerm; + }) + : approvers; + + return [ + { + title: undefined, + data: OptionsListUtils.sortAlphabetically(filteredApprovers, 'text'), + shouldShow: true, + }, + ]; + }, [debouncedSearchTerm, personalDetails, policy?.employeeList, selectedApprover, translate]); + + const handleOnSelectRow = (approver: SelectionListApprover) => { + setApprover(approver.login); + }; + + const headerMessage = useMemo(() => (searchTerm && !sections[0].data.length ? translate('common.noResultsFound') : ''), [searchTerm, sections, translate]); + + return ( + <SelectionList + sections={sections} + ListItem={InviteMemberListItem} + textInputLabel={translate('selectionList.nameEmailOrPhoneNumber')} + textInputValue={searchTerm} + onChangeText={setSearchTerm} + headerMessage={headerMessage} + onSelectRow={handleOnSelectRow} + showScrollIndicator + showLoadingPlaceholder={!didScreenTransitionEnd} + shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} + /> + ); +} + +export default WorkspaceMembersSelectionList; diff --git a/src/hooks/useDelegateUserDetails.ts b/src/hooks/useDelegateUserDetails.ts new file mode 100644 index 000000000000..6e90f2906277 --- /dev/null +++ b/src/hooks/useDelegateUserDetails.ts @@ -0,0 +1,18 @@ +import {useOnyx} from 'react-native-onyx'; +import AccountUtils from '@libs/AccountUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; + +function useDelegateUserDetails() { + const currentUserDetails = useCurrentUserPersonalDetails(); + const [currentUserAccountDetails] = useOnyx(ONYXKEYS.ACCOUNT); + const isDelegateAccessRestricted = AccountUtils.isDelegateOnlySubmitter(currentUserAccountDetails); + const delegatorEmail = currentUserDetails?.login; + + return { + isDelegateAccessRestricted, + delegatorEmail, + }; +} + +export default useDelegateUserDetails; diff --git a/src/languages/en.ts b/src/languages/en.ts index 762b03585e16..d370cc80c6c4 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2,9 +2,9 @@ import {CONST as COMMON_CONST, Str} from 'expensify-common'; import {startCase} from 'lodash'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; -import type {DelegateRole} from '@src/types/onyx/Account'; import type {ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName} from '@src/types/onyx/Policy'; import type { + AccountOwnerParams, AddressLineParams, AdminCanceledRequestParams, AlreadySignedInParams, @@ -68,6 +68,7 @@ import type { RemoveMembersWarningPrompt, RenamedRoomActionParams, ReportArchiveReasonsClosedParams, + ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams, ReportArchiveReasonsMergedParams, ReportArchiveReasonsPolicyDeletedParams, ReportArchiveReasonsRemovedFromPolicyParams, @@ -147,6 +148,7 @@ export default { buttonConfirm: 'Got it', name: 'Name', attachment: 'Attachment', + attachments: 'Attachments', center: 'Center', from: 'From', to: 'To', @@ -178,6 +180,7 @@ export default { profile: 'Profile', referral: 'Referral', payments: 'Payments', + approvals: 'Approvals', wallet: 'Wallet', preferences: 'Preferences', view: 'View', @@ -253,6 +256,7 @@ export default { conjunctionAt: 'at', conjunctionTo: 'to', genericErrorMessage: 'Oops... something went wrong and your request could not be completed. Please try again later.', + percentage: 'Percentage', error: { invalidAmount: 'Invalid amount.', acceptTerms: 'You must accept the Terms of Service to continue.', @@ -385,9 +389,12 @@ export default { ignore: 'Ignore', enabled: 'Enabled', import: 'Import', - importSpreadsheet: 'Import spreadsheet', offlinePrompt: "You can't take this action right now.", outstanding: 'Outstanding', + chats: 'Chats', + unread: 'Unread', + sent: 'Sent', + links: 'Links', days: 'days', }, location: { @@ -542,16 +549,6 @@ export default { sendAttachment: 'Send attachment', addAttachment: 'Add attachment', writeSomething: 'Write something...', - conciergePlaceholderOptions: [ - 'Ask for help!', - 'Ask me anything!', - 'Ask me to book travel!', - 'Ask me what I can do!', - 'Ask me how to pay people!', - 'Ask me how to send an invoice!', - 'Ask me how to scan a receipt!', - 'Ask me how to get a free corporate card!', - ], blockedFromConcierge: 'Communication is barred', fileUploadFailed: 'Upload failed. File is not supported.', localTime: ({user, time}: LocalTimeParams) => `It's ${time} for ${user}`, @@ -644,6 +641,8 @@ export default { : `This chat is no longer active because ${displayName} is no longer a member of the ${policyName} workspace.`, [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}: ReportArchiveReasonsPolicyDeletedParams) => `This chat is no longer active because ${policyName} is no longer an active workspace.`, + [CONST.REPORT.ARCHIVE_REASON.INVOICE_RECEIVER_POLICY_DELETED]: ({policyName}: ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams) => + `This chat is no longer active because ${policyName} is no longer an active workspace.`, [CONST.REPORT.ARCHIVE_REASON.BOOKING_END_DATE_HAS_PASSED]: 'This booking is archived.', }, writeCapabilityPage: { @@ -663,6 +662,7 @@ export default { listOfChatMessages: 'List of chat messages', listOfChats: 'List of chats', saveTheWorld: 'Save the world', + tooltip: 'Get started here!', }, allSettingsScreen: { subscription: 'Subscription', @@ -687,8 +687,11 @@ export default { importCategoriesSuccessfullDescription: (categories: number) => (categories > 1 ? `${categories} categories have been added.` : '1 category has been added.'), importFailedTitle: 'Import failed', importFailedDescription: 'Please ensure all fields are filled out correctly and try again. If the problem persists, please reach out to Concierge.', + sizeNotMet: 'File size must be greater than 0 bytes', invalidFileMessage: 'The file you uploaded is either empty or contains invalid data. Please ensure that the file is correctly formatted and contains the necessary information before uploading it again.', + importSpreadsheet: 'Import spreadsheet', + downloadCSV: 'Download CSV', }, receipt: { upload: 'Upload receipt', @@ -804,9 +807,6 @@ export default { settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pay ${formattedAmount}`, settleBusiness: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} as a business` : `Pay as a business`), payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} elsewhere` : `Pay elsewhere`), - settlePersonalBank: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with personal bank account` : `Pay with personal bank account`), - settleBusinessBank: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with business bank account` : `Pay with business bank account`), - settleDebitCard: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with debit card` : `Pay with debit card`), nextStep: 'Next steps', finished: 'Finished', sendInvoice: ({amount}: RequestAmountParams) => `Send ${amount} invoice`, @@ -864,10 +864,9 @@ export default { genericHoldExpenseFailureMessage: 'Unexpected error holding this expense. Please try again later.', genericUnholdExpenseFailureMessage: 'Unexpected error taking this expense off hold. Please try again later.', receiptDeleteFailureError: 'Unexpected error deleting this receipt. Please try again later.', - // eslint-disable-next-line rulesdir/use-periods-for-error-messages receiptFailureMessage: "The receipt didn't upload.", - saveFileMessage: 'Download the file ', // eslint-disable-next-line rulesdir/use-periods-for-error-messages + saveFileMessage: 'Download the file ', loseFileMessage: 'or dismiss this error and lose it.', genericDeleteFailureMessage: 'Unexpected error deleting this expense. Please try again later.', genericEditFailureMessage: 'Unexpected error editing this expense. Please try again later.', @@ -1330,6 +1329,8 @@ export default { updateAddress: 'Update address', }, cardDetailsLoadingFailure: 'An error occurred while loading the card details. Please check your internet connection and try again.', + validateCardTitle: "Let's make sure it's you", + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to view your card details`, }, workflowsPage: { workflowTitle: 'Spend', @@ -1520,7 +1521,6 @@ export default { groupName: 'Group name', }, groupChat: { - groupMembersListTitle: 'Directory of all group members.', lastMemberTitle: 'Heads up!', lastMemberWarning: "Since you're the last person here, leaving will make this chat inaccessible to all users. Are you sure you want to leave?", defaultReportName: ({displayName}: {displayName: string}) => `${displayName}'s group chat`, @@ -1765,7 +1765,7 @@ export default { connectManually: 'Connect manually', desktopConnection: 'Note: To connect with Chase, Wells Fargo, Capital One or Bank of America, please click here to complete this process in a browser.', yourDataIsSecure: 'Your data is secure', - toGetStarted: 'Add a bank account to reimburse expenses, issue corporate cards, collect invoice payments, and pay bills all from one place.', + toGetStarted: 'Add a bank account to reimburse expenses, issue Expensify Cards, collect invoice payments, and pay bills all from one place.', plaidBodyCopy: 'Give your employees an easier way to pay - and get paid back - for company expenses.', checkHelpLine: 'Your routing number and account number can be found on a check for the account.', validateAccountError: { @@ -1778,33 +1778,33 @@ export default { hasBeenThrottledError: 'An error occurred while adding your bank account. Please wait a few minutes and try again.', hasCurrencyError: 'Oops! It appears that your workspace currency is set to a different currency than USD. To proceed, please set it to USD and try again.', error: { - youNeedToSelectAnOption: 'Please select an option to proceed', - noBankAccountAvailable: "Sorry, there's no bank account available", - noBankAccountSelected: 'Please choose an account', - taxID: 'Please enter a valid tax ID number', - website: 'Please enter a valid website using lower-case letters', - zipCode: `Please enter a valid ZIP code using the format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`, - phoneNumber: 'Please enter a valid phone number', - companyName: 'Please enter a valid business name', - addressCity: 'Please enter a valid city', - addressStreet: 'Please enter a valid street address', - addressState: 'Please select a valid state', - incorporationDateFuture: "Incorporation date can't be in the future", - incorporationState: 'Please select a valid state', - industryCode: 'Please enter a valid industry classification code with six digits', - restrictedBusiness: "Please confirm the business isn't on the list of restricted businesses", - routingNumber: 'Please enter a valid routing number', - accountNumber: 'Please enter a valid account number', - routingAndAccountNumberCannotBeSame: "Routing and account numbers can't match", - companyType: 'Please select a valid company type', + youNeedToSelectAnOption: 'Please select an option to proceed.', + noBankAccountAvailable: "Sorry, there's no bank account available.", + noBankAccountSelected: 'Please choose an account.', + taxID: 'Please enter a valid tax ID number.', + website: 'Please enter a valid website using lower-case letters.', + zipCode: `Please enter a valid ZIP code using the format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}.`, + phoneNumber: 'Please enter a valid phone number.', + companyName: 'Please enter a valid business name.', + addressCity: 'Please enter a valid city.', + addressStreet: 'Please enter a valid street address.', + addressState: 'Please select a valid state.', + incorporationDateFuture: "Incorporation date can't be in the future.", + incorporationState: 'Please select a valid state.', + industryCode: 'Please enter a valid industry classification code with six digits.', + restrictedBusiness: "Please confirm the business isn't on the list of restricted businesses.", + routingNumber: 'Please enter a valid routing number.', + accountNumber: 'Please enter a valid account number.', + routingAndAccountNumberCannotBeSame: "Routing and account numbers can't match.", + companyType: 'Please select a valid company type.', tooManyAttempts: 'Due to a high number of login attempts, this option has been disabled for 24 hours. Please try again later or enter details manually instead.', - address: 'Please enter a valid address', - dob: 'Please select a valid date of birth', - age: 'Must be over 18 years old', - ssnLast4: 'Please enter valid last 4 digits of SSN', - firstName: 'Please enter a valid first name', - lastName: 'Please enter a valid last name', - noDefaultDepositAccountOrDebitCardAvailable: 'Please add a default deposit account or debit card', + address: 'Please enter a valid address.', + dob: 'Please select a valid date of birth.', + age: 'Must be over 18 years old.', + ssnLast4: 'Please enter valid last 4 digits of SSN.', + firstName: 'Please enter a valid first name.', + lastName: 'Please enter a valid last name.', + noDefaultDepositAccountOrDebitCardAvailable: 'Please add a default deposit account or debit card.', validationAmounts: 'The validation amounts you entered are incorrect. Please double check your bank statement and try again.', }, }, @@ -2155,7 +2155,7 @@ export default { common: { card: 'Cards', expensifyCard: 'Expensify Card', - companyCards: 'Company Cards', + companyCards: 'Company cards', workflows: 'Workflows', workspace: 'Workspace', edit: 'Edit workspace', @@ -2279,6 +2279,7 @@ export default { accountsPayable: 'Accounts payable', accountsPayableDescription: 'Choose where to create vendor bills.', bankAccount: 'Bank account', + notConfigured: 'Not configured', bankAccountDescription: 'Choose where to send checks from.', creditCardAccount: 'Credit card account', companyCardsLocationEnabledDescription: @@ -2795,6 +2796,43 @@ export default { companyCards: { addCompanyCards: 'Add company cards', selectCardFeed: 'Select card feed', + addCardFeed: 'Add card feed', + addNewCard: { + cardProviders: { + amex: 'American Express Corporate Cards', + mastercard: 'Mastercard Commercial Cards', + visa: 'Visa Commercial Cards', + }, + yourCardProvider: `Who's your card provider?`, + enableFeed: { + title: (provider: string) => `Enable your ${provider} feed`, + heading: 'We have a direct integration with your card issuer and can import your transaction data into Expensify quickly and accurately.\n\nTo get started, simply:', + visa: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) for detailed instructionson how to set up your Visa Commercial Cards.\n\n2. [Contact your bank](${CONST.COMPANY_CARDS_HELP}) to verify they support a custom feed for your program, and ask them toenable it.\n\n3. *Once the feed is enabled and you have its details, continue to the next screen.*`, + amex: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) to find out if American Express can enable a custom feed for your program.\n\n2. Once the feed is enabled, Amex will send you a production letter.\n\n3. *Once you have the feed information, continue to the next screen.*`, + mastercard: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) for detailed instructions on how to set up your Mastercard Commercial Cards.\n\n 2. [Contact your bank](${CONST.COMPANY_CARDS_HELP}) to verify they support a custom feed for your program, and ask them to enable it.\n\n3. *Once the feed is enabled and you have its details, continue to the next screen.*`, + }, + whatBankIssuesCard: 'What bank issues these cards?', + enterNameOfBank: 'Enter name of bank', + feedDetails: { + visa: { + title: 'What are the Visa feed details?', + processorLabel: 'Processor ID', + bankLabel: 'Financial institution (bank) ID', + companyLabel: 'Company ID', + }, + amex: { + title: `What's the Amex delivery file name`, + fileNameLabel: 'Delivery file name', + }, + mastercard: { + title: `What's the Mastercard distribution ID`, + distributionLabel: 'Distribution ID', + }, + }, + error: { + pleaseSelectProvider: 'Please select a card provider before continuing.', + }, + }, assignCard: 'Assign card', cardNumber: 'Card number', customFeed: 'Custom feed', @@ -2938,23 +2976,23 @@ export default { }, expensifyCard: { title: 'Expensify Card', - subtitle: 'Gain insights and control over spend', + subtitle: 'Gain insights and control over spend.', disableCardTitle: 'Disable Expensify Card', disableCardPrompt: 'You canβt disable the Expensify Card because itβs already in use. Reach out to Concierge for next steps.', disableCardButton: 'Chat with Concierge', feed: { title: 'Get the Expensify Card', - subTitle: 'Streamline your business with the Expensify Card', + subTitle: 'Streamline your business with the Expensify Card.', features: { - cashBack: 'Up to 2% cash back on every US purchase', - unlimited: 'Issue unlimited virtual cards', + cashBack: 'Cash back on every US purchase', + unlimited: 'Unlimited virtual cards', spend: 'Spend controls and custom limits', }, ctaTitle: 'Issue new card', }, }, companyCards: { - title: 'Company Cards', + title: 'Company cards', subtitle: 'Import spend from existing company cards.', feed: { title: 'Import company cards', @@ -2965,7 +3003,7 @@ export default { }, ctaTitle: 'Add company cards', }, - disableCardTitle: 'Disable Company Cards', + disableCardTitle: 'Disable company cards', disableCardPrompt: 'You canβt disable company cards because this feature is in use. Reach out to the Concierge for next steps.', disableCardButton: 'Chat with Concierge', assignCard: 'Assign card', @@ -3164,8 +3202,9 @@ export default { removeMembersWarningPrompt: ({memberName, ownerName}: RemoveMembersWarningPrompt) => `${memberName} is an approver in this workspace. When you unshare this workspace with them, weβll replace them in the approval workflow with the workspace owner, ${ownerName}`, removeMembersTitle: 'Remove members', - removeMemberButtonTitle: 'Remove from workspace', - removeMemberGroupButtonTitle: 'Remove from group', + removeWorkspaceMemberButtonTitle: 'Remove from workspace', + removeGroupMemberButtonTitle: 'Remove from group', + removeRoomMemberButtonTitle: 'Remove from chat', removeMemberPrompt: ({memberName}: {memberName: string}) => `Are you sure you want to remove ${memberName}?`, removeMemberTitle: 'Remove member', transferOwner: 'Transfer owner', @@ -3213,7 +3252,7 @@ export default { fixedAmountDescription: 'Spend up to a certain amount once', setLimit: 'Set a limit', giveItName: 'Give it a name', - giveItNameInstruction: 'Make it unique enough to tell apart from the other. Specific use cases are even better!', + giveItNameInstruction: 'Make it unique enough to tell apart from other cards. Specific use cases are even better!', cardName: 'Card name', letsDoubleCheck: 'Letβs double check that everything looks right.', willBeReady: 'This card will be ready to use immediately.', @@ -3566,6 +3605,8 @@ export default { continueWithSetup: 'Continue with setup', youreAlmostDone: "You're almost done setting up your bank account, which will let you issue corporate cards, reimburse expenses, collect invoices, and pay bills.", streamlinePayments: 'Streamline payments', + connectBankAccountNote: "Note: Personal bank accounts can't be used for payments on workspaces.", + oneMoreThing: 'One more thing!', allSet: "You're all set!", accountDescriptionWithCards: 'This bank account will be used to issue corporate cards, reimburse expenses, collect invoices, and pay bills.', letsFinishInChat: "Let's finish in chat!", @@ -3670,7 +3711,7 @@ export default { onlyAvailableOnPlan: 'Tax codes are only available on the Control plan, starting at ', }, companyCards: { - title: 'Company Cards', + title: 'Company cards', description: `Company cards lets you import spend for existing company cards from all major card issuers. You can assign cards to employees, and automatically import transactions.`, onlyAvailableOnPlan: 'Company cards are only available on the Control plan, starting at ', }, @@ -3733,8 +3774,66 @@ export default { eReceiptsHintLink: 'for most USD credit transactions', }, expenseReportRules: { + examples: 'Examples:', title: 'Expense reports', subtitle: 'Automate expense report compliance, approvals, and payment.', + customReportNamesTitle: 'Custom report names', + customReportNamesSubtitle: 'Create custom names using our extensive formulas.', + customNameTitle: 'Custom name', + customNameDescription: 'Choose a custom name for expense reports using our ', + customNameDescriptionLink: 'extensive formulas', + customNameInputLabel: 'Name', + customNameEmailPhoneExample: 'Memberβs email or phone: {report:submit:from}', + customNameStartDateExample: 'Report start date: {report:startdate}', + customNameWorkspaceNameExample: 'Workspace name: {report:policyname}', + customNameReportIDExample: 'Report ID: {report:id}', + customNameTotalExample: 'Total: {report:total}.', + preventMembersFromChangingCustomNamesTitle: 'Prevent members from changing custom report names', + preventSelfApprovalsTitle: 'Prevent self-approvals', + preventSelfApprovalsSubtitle: 'Prevent workspace members from approving their own expense reports.', + autoApproveCompliantReportsTitle: 'Auto-approve compliant reports', + autoApproveCompliantReportsSubtitle: 'Configure which expense reports are eligible for auto-approval.', + autoApproveReportsUnderTitle: 'Auto-approve reports under', + autoApproveReportsUnderDescription: 'Fully compliant expense reports under this amount will be automatically approved.', + randomReportAuditTitle: 'Random report audit', + randomReportAuditDescription: 'Require that some reports be manually approved, even if eligible for auto-approval.', + autoPayApprovedReportsTitle: 'Auto-pay approved reports', + autoPayApprovedReportsSubtitle: 'Configure which expense reports are eligible for auto-pay.', + autoPayApprovedReportsLimitError: (currency?: string) => `Please enter an amount less than ${currency ?? ''}20,000`, + autoPayApprovedReportsLockedSubtitle: 'Go to more features and enable workflows, then add payments to unlock this feature.', + autoPayReportsUnderTitle: 'Auto-pay reports under', + autoPayReportsUnderDescription: 'Fully compliant expense reports under this amount will be automatically paid. ', + unlockFeatureGoToSubtitle: 'Go to', + unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `and enable workflows, then add ${featureName} to unlock this feature.`, + }, + categoryRules: { + title: 'Category rules', + approver: 'Approver', + requireDescription: 'Require description', + descriptionHint: 'Description hint', + descriptionHintDescription: (categoryName: string) => + `Remind employees to provide additional information for β${categoryName}β spend. This hint appears in the description field on expenses.`, + descriptionHintLabel: 'Hint', + descriptionHintSubtitle: 'Pro-tip: The shorter the better!', + maxAmount: 'Max amount', + flagAmountsOver: 'Flag amounts over', + flagAmountsOverDescription: (categoryName) => `Applies to the category β${categoryName}β.`, + flagAmountsOverSubtitle: 'This overrides the max amount for all expenses.', + expenseLimitTypes: { + expense: 'Individual expense', + expenseSubtitle: 'Flag expense amounts by category. This rule overrides the general workspace rule for max expense amount.', + daily: 'Category total', + dailySubtitle: 'Flag total category spend per expense report.', + }, + requireReceiptsOver: 'Require receipts over', + requireReceiptsOverList: { + default: (defaultAmount: string) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Default`, + never: 'Never require receipts', + always: 'Always require receipts', + }, + defaultTaxRate: 'Default tax rate', + goTo: 'Go to', + andEnableWorkflows: 'and enable workflows, then add approvals to unlock this feature.', }, }, }, @@ -3792,9 +3891,23 @@ export default { }, workspaceActions: { renamedWorkspaceNameAction: ({oldName, newName}) => `updated the name of this workspace from ${oldName} to ${newName}`, + removedFromApprovalWorkflow: ({submittersNames}: {submittersNames: string[]}) => { + let joinedNames = ''; + if (submittersNames.length === 1) { + joinedNames = submittersNames[0]; + } else if (submittersNames.length === 2) { + joinedNames = submittersNames.join(' and '); + } else if (submittersNames.length > 2) { + joinedNames = `${submittersNames.slice(0, submittersNames.length - 1).join(', ')} and ${submittersNames[submittersNames.length - 1]}`; + } + const workflowWord = Str.pluralize('workflow', 'workflows', submittersNames.length); + const chatWord = Str.pluralize('chat', 'chats', submittersNames.length); + return `removed you from ${joinedNames}'s approval ${workflowWord} and workspace ${chatWord}. Previously submitted reports will remain available for approval in your Inbox.`; + }, }, roomMembersPage: { - memberNotFound: 'Member not found. To invite a new member to the room, please use the invite button above.', + memberNotFound: 'Member not found.', + useInviteButton: 'To invite a new member to the chat, please use the invite button above.', notAuthorized: `You don't have access to this page. If you're trying to join this room, just ask a room member to add you. Something else? Reach out to ${CONST.EMAIL.CONCIERGE}`, removeMembersPrompt: 'Are you sure you want to remove the selected members from the room?', error: { @@ -3882,13 +3995,16 @@ export default { keyword: 'Keyword', hasKeywords: 'Has keywords', currency: 'Currency', - has: 'Has', link: 'Link', + pinned: 'Pinned', + unread: 'Unread', amount: { lessThan: (amount?: string) => `Less than ${amount ?? ''}`, greaterThan: (amount?: string) => `Greater than ${amount ?? ''}`, between: (greaterThan: string, lessThan: string) => `Between ${greaterThan} and ${lessThan}`, }, + current: 'Current', + past: 'Past', }, expenseType: 'Expense type', }, @@ -4587,7 +4703,12 @@ export default { }, delegate: { switchAccount: 'Switch accounts:', - role: (role: DelegateRole): string => { + copilotDelegatedAccess: 'Copilot: Delegated access', + copilotDelegatedAccessDescription: 'Allow other members to access your account.', + addCopilot: 'Add copilot', + membersCanAccessYourAccount: 'These members can access your account:', + youCanAccessTheseAccounts: 'You can access these accounts via the account switcher:', + role: (role?: string): string => { switch (role) { case CONST.DELEGATE_ROLE.ALL: return 'Full'; @@ -4598,5 +4719,24 @@ export default { } }, genericError: 'Oops, something went wrong. Please try again.', + accessLevel: 'Access level', + confirmCopilot: 'Confirm your copilot below.', + accessLevelDescription: 'Choose an access level below. Both Full and Limited access allow copilots to view all conversations and expenses.', + roleDescription: (role?: string): string => { + switch (role) { + case CONST.DELEGATE_ROLE.ALL: + return 'Allow another member to take all actions in your account, on your behalf. Includes chat, submissions, approvals, payments, settings updates, and more.'; + case CONST.DELEGATE_ROLE.SUBMITTER: + return 'Allow another member to take most actions in your account, on your behalf. Excludes approvals, payments, rejections, and holds.'; + default: + return ''; + } + }, + makeSureItIsYou: "Let's make sure it's you", + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to add a copilot.`, + notAllowed: 'Not so fast...', + notAllowedMessageStart: ({accountOwnerEmail}: AccountOwnerParams) => `You don't have permission to take this action for ${accountOwnerEmail} as a`, + notAllowedMessageHyperLinked: ' limited access', + notAllowedMessageEnd: ' copilot', }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index be7c50d57858..938430fc422d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1,8 +1,8 @@ import {Str} from 'expensify-common'; import CONST from '@src/CONST'; -import type {DelegateRole} from '@src/types/onyx/Account'; import type {ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName} from '@src/types/onyx/Policy'; import type { + AccountOwnerParams, AddressLineParams, AdminCanceledRequestParams, AlreadySignedInParams, @@ -67,6 +67,7 @@ import type { RemoveMembersWarningPrompt, RenamedRoomActionParams, ReportArchiveReasonsClosedParams, + ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams, ReportArchiveReasonsMergedParams, ReportArchiveReasonsPolicyDeletedParams, ReportArchiveReasonsRemovedFromPolicyParams, @@ -137,6 +138,7 @@ export default { buttonConfirm: 'Ok, entendido', name: 'Nombre', attachment: 'Archivo adjunto', + attachments: 'Archivos adjuntos', from: 'De', to: 'A', in: 'En', @@ -168,6 +170,7 @@ export default { profile: 'Perfil', referral: 'RemisiΓ³n', payments: 'Pagos', + approvals: 'Aprobaciones', wallet: 'Billetera', preferences: 'Preferencias', view: 'Ver', @@ -243,6 +246,7 @@ export default { conjunctionAt: 'a', conjunctionTo: 'a', genericErrorMessage: 'Ups... algo no ha ido bien y la acciΓ³n no se ha podido completar. Por favor, intΓ©ntalo mΓ‘s tarde.', + percentage: 'Porcentaje', error: { invalidAmount: 'Importe no vΓ‘lido.', acceptTerms: 'Debes aceptar los TΓ©rminos de Servicio para continuar.', @@ -369,7 +373,6 @@ export default { filterLogs: 'Registros de filtrado', network: 'La red', reportID: 'ID del informe', - importSpreadsheet: 'Importar hoja de cΓ‘lculo', chooseFile: 'Elegir archivo', dropTitle: 'SuΓ©ltalo', dropMessage: 'Suelta tu archivo aquΓ', @@ -378,6 +381,10 @@ export default { import: 'Importar', offlinePrompt: 'No puedes realizar esta acciΓ³n ahora mismo.', outstanding: 'Pendiente', + chats: 'Chats', + unread: 'No leΓdo', + sent: 'Enviado', + links: 'Enlaces', days: 'dΓas', }, connectionComplete: { @@ -533,16 +540,6 @@ export default { sendAttachment: 'Enviar adjunto', addAttachment: 'AΓ±adir archivo adjunto', writeSomething: 'Escribe algo...', - conciergePlaceholderOptions: [ - 'Β‘Pide ayuda!', - 'Β‘PregΓΊntame lo que sea!', - 'Β‘PΓdeme que te reserve un viaje!', - 'Β‘PregΓΊntame quΓ© puedo hacer!', - 'Β‘PregΓΊntame cΓ³mo pagar a la gente!', - 'Β‘PregΓΊntame cΓ³mo enviar una factura!', - 'Β‘PregΓΊntame cΓ³mo escanear un recibo!', - 'Β‘PregΓΊntame cΓ³mo obtener una tarjeta de crΓ©dito corporativa gratis!', - ], blockedFromConcierge: 'ComunicaciΓ³n no permitida', fileUploadFailed: 'Subida fallida. El archivo no es compatible.', localTime: ({user, time}: LocalTimeParams) => `Son las ${time} para ${user}`, @@ -637,6 +634,8 @@ export default { : `Este chat estΓ‘ desactivado porque ${displayName} ha dejado de ser miembro del espacio de trabajo ${policyName}.`, [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}: ReportArchiveReasonsPolicyDeletedParams) => `Este chat estΓ‘ desactivado porque el espacio de trabajo ${policyName} se ha eliminado.`, + [CONST.REPORT.ARCHIVE_REASON.INVOICE_RECEIVER_POLICY_DELETED]: ({policyName}: ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams) => + `Este chat estΓ‘ desactivado porque el espacio de trabajo ${policyName} se ha eliminado.`, [CONST.REPORT.ARCHIVE_REASON.BOOKING_END_DATE_HAS_PASSED]: 'Esta reserva estΓ‘ archivada.', }, writeCapabilityPage: { @@ -656,6 +655,7 @@ export default { listOfChatMessages: 'Lista de mensajes del chat', listOfChats: 'lista de chats', saveTheWorld: 'Salvar el mundo', + tooltip: 'Β‘Comienza aquΓ!', }, allSettingsScreen: { subscription: 'Suscripcion', @@ -680,8 +680,11 @@ export default { importFailedDescription: 'Por favor, asegΓΊrate de que todos los campos estΓ©n llenos correctamente e intΓ©ntalo de nuevo. Si el problema persiste, por favor contacta a Concierge.', importCategoriesSuccessfullDescription: (categories: number) => (categories > 1 ? `Se han agregado ${categories} categorΓas.` : 'Se ha agregado 1 categorΓa.'), importSuccessfullTitle: 'Importar categorΓas', + sizeNotMet: 'El archivo adjunto debe ser mΓ‘s grande que 0 bytes.', invalidFileMessage: - 'El archivo que ha cargado estΓ‘ vacΓo o contiene datos no vΓ‘lidos. AsegΓΊrese de que el archivo tiene el formato correcto y contiene la informaciΓ³n necesaria antes de volver a cargarlo.', + 'El archivo que subiste estΓ‘ vacΓo o contiene datos no vΓ‘lidos. AsegΓΊrate de que el archivo estΓ© correctamente formateado y contenga la informaciΓ³n necesaria antes de volver a subirlo.', + importSpreadsheet: 'Importar hoja de cΓ‘lculo', + downloadCSV: 'Descargar CSV', }, receipt: { upload: 'Subir recibo', @@ -797,11 +800,6 @@ export default { settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pagar ${formattedAmount}`, settleBusiness: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} como negocio` : `Pagar como empresa`), payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} de otra forma` : `Pagar de otra forma`), - settlePersonalBank: ({formattedAmount}: SettleExpensifyCardParams) => - formattedAmount ? `Pagar ${formattedAmount} con cuenta bancaria personal` : `Pagar con cuenta bancaria personal`, - settleBusinessBank: ({formattedAmount}: SettleExpensifyCardParams) => - formattedAmount ? `Pagar ${formattedAmount} con cuenta bancaria comercial` : `Pagar con cuenta bancaria comercial`, - settleDebitCard: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} con tarjeta de dΓ©bito` : `Pagar con tarjeta de dΓ©bito`), nextStep: 'Pasos siguientes', finished: 'Finalizado', sendInvoice: ({amount}: RequestAmountParams) => `Enviar factura de ${amount}`, @@ -861,10 +859,9 @@ export default { genericCreateFailureMessage: 'Error inesperado al enviar este gasto. Por favor, intΓ©ntalo mΓ‘s tarde.', genericCreateInvoiceFailureMessage: 'Error inesperado al enviar la factura. Por favor, intΓ©ntalo de nuevo mΓ‘s tarde.', receiptDeleteFailureError: 'Error inesperado al borrar este recibo. Por favor, vuelve a intentarlo mΓ‘s tarde.', - // eslint-disable-next-line rulesdir/use-periods-for-error-messages receiptFailureMessage: 'El recibo no se subiΓ³.', - saveFileMessage: 'Guarda el archivo ', // eslint-disable-next-line rulesdir/use-periods-for-error-messages + saveFileMessage: 'Guarda el archivo ', loseFileMessage: 'o descarta este error y piΓ©rdelo.', genericDeleteFailureMessage: 'Error inesperado al eliminar este gasto. Por favor, intΓ©ntalo mΓ‘s tarde.', genericEditFailureMessage: 'Error inesperado al editar este gasto. Por favor, intΓ©ntalo mΓ‘s tarde.', @@ -1338,6 +1335,8 @@ export default { updateAddress: 'Actualizar direcciΓ³n', }, cardDetailsLoadingFailure: 'Se ha producido un error al cargar los datos de la tarjeta. Comprueba tu conexiΓ³n a Internet e intΓ©ntalo de nuevo.', + validateCardTitle: 'AsegurΓ©monos de que eres tΓΊ', + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Introduzca el cΓ³digo mΓ‘gico enviado a ${contactMethod} para ver los datos de su tarjeta`, }, workflowsPage: { workflowTitle: 'Gasto', @@ -1531,7 +1530,6 @@ export default { groupName: 'Nombre del grupo', }, groupChat: { - groupMembersListTitle: 'Directorio de los miembros del grupo.', lastMemberTitle: 'Β‘AtenciΓ³n!', lastMemberWarning: 'Ya que eres la ΓΊltima persona aquΓ, si te vas, este chat quedarΓ‘ inaccesible para todos los miembros. ΒΏEstΓ‘s seguro de que quieres salir del chat?', defaultReportName: ({displayName}: {displayName: string}) => `Chat de grupo de ${displayName}`, @@ -1793,7 +1791,7 @@ export default { connectManually: 'Conectar manualmente', desktopConnection: 'Para conectarse con Chase, Wells Fargo, Capital One o Bank of America, haz clic aquΓ para completar este proceso en un navegador.', yourDataIsSecure: 'Tus datos estΓ‘n seguros', - toGetStarted: 'Conecta una cuenta bancaria para reembolsar gastos, emitir tarjetas corporativas, y cobrar y pagar facturas todo desde un mismo lugar.', + toGetStarted: 'Conecta una cuenta bancaria para reembolsar gastos, emitir Tarjetas Expensify, y cobrar y pagar facturas todo desde un mismo lugar.', plaidBodyCopy: 'Ofrezca a sus empleados una forma mΓ‘s sencilla de pagar - y recuperar - los gastos de la empresa.', checkHelpLine: 'Tus nΓΊmeros de ruta y de cuenta se pueden encontrar en un cheque de la cuenta bancaria.', validateAccountError: { @@ -1807,34 +1805,34 @@ export default { hasBeenThrottledError: 'Se ha producido un error al intentar aΓ±adir tu cuenta bancaria. Por favor, espera unos minutos e intΓ©ntalo de nuevo.', hasCurrencyError: 'Β‘Ups! Parece que la moneda de tu espacio de trabajo no estΓ‘ configurada en USD. Por favor, configΓΊrala en USD e intΓ©ntalo nuevamente.', error: { - youNeedToSelectAnOption: 'Debes seleccionar una opciΓ³n para continuar', - noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible', - noBankAccountSelected: 'Por favor, elige una cuenta bancaria', - taxID: 'Por favor, introduce un nΓΊmero de identificaciΓ³n fiscal vΓ‘lido', + youNeedToSelectAnOption: 'Debes seleccionar una opciΓ³n para continuar.', + noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible.', + noBankAccountSelected: 'Por favor, elige una cuenta bancaria.', + taxID: 'Por favor, introduce un nΓΊmero de identificaciΓ³n fiscal vΓ‘lido.', website: 'Por favor, introduce un sitio web vΓ‘lido. El sitio web debe estar en minΓΊsculas.', - zipCode: `Formato de cΓ³digo postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`, - phoneNumber: 'Por favor, introduce un telΓ©fono vΓ‘lido', - companyName: 'Por favor, introduce un nombre comercial legal vΓ‘lido', - addressCity: 'Por favor, introduce una ciudad vΓ‘lida', - addressStreet: 'Por favor, introduce una calle de direcciΓ³n vΓ‘lida que no sea un apartado postal', - addressState: 'Por favor, selecciona un estado', - incorporationDateFuture: 'La fecha de incorporaciΓ³n no puede ser futura', - incorporationState: 'Por favor, selecciona una estado vΓ‘lido', - industryCode: 'Por favor, introduce un cΓ³digo de clasificaciΓ³n de industria vΓ‘lido', - restrictedBusiness: 'Por favor, confirma que la empresa no estΓ‘ en la lista de negocios restringidos', - routingNumber: 'Por favor, introduce un nΓΊmero de ruta vΓ‘lido', - accountNumber: 'Por favor, introduce un nΓΊmero de cuenta vΓ‘lido', - routingAndAccountNumberCannotBeSame: 'Los nΓΊmeros de ruta y de cuenta no pueden ser iguales', - companyType: 'Por favor, selecciona un tipo de compaΓ±Γa vΓ‘lido', + zipCode: `Formato de cΓ³digo postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}.`, + phoneNumber: 'Por favor, introduce un telΓ©fono vΓ‘lido.', + companyName: 'Por favor, introduce un nombre comercial legal vΓ‘lido.', + addressCity: 'Por favor, introduce una ciudad vΓ‘lida.', + addressStreet: 'Por favor, introduce una direcciΓ³n vΓ‘lida que no sea un apartado postal.', + addressState: 'Por favor, selecciona un estado.', + incorporationDateFuture: 'La fecha de incorporaciΓ³n no puede ser futura.', + incorporationState: 'Por favor, selecciona una estado vΓ‘lido.', + industryCode: 'Por favor, introduce un cΓ³digo de clasificaciΓ³n de industria vΓ‘lido.', + restrictedBusiness: 'Por favor, confirma que la empresa no estΓ‘ en la lista de negocios restringidos.', + routingNumber: 'Por favor, introduce un nΓΊmero de ruta vΓ‘lido.', + accountNumber: 'Por favor, introduce un nΓΊmero de cuenta vΓ‘lido.', + routingAndAccountNumberCannotBeSame: 'Los nΓΊmeros de ruta y de cuenta no pueden ser iguales.', + companyType: 'Por favor, selecciona un tipo de compaΓ±Γa vΓ‘lido.', tooManyAttempts: 'Debido a la gran cantidad de intentos de inicio de sesiΓ³n, esta opciΓ³n ha sido desactivada temporalmente durante 24 horas. Por favor, intΓ©ntalo de nuevo mΓ‘s tarde.', - address: 'Por favor, introduce una direcciΓ³n vΓ‘lida', - dob: 'Por favor, selecciona una fecha de nacimiento vΓ‘lida', - age: 'Debe ser mayor de 18 aΓ±os', - ssnLast4: 'Por favor, introduce los ΓΊltimos 4 dΓgitos del nΓΊmero de seguridad social', - firstName: 'Por favor, introduce el nombre', - lastName: 'Por favor, introduce los apellidos', - noDefaultDepositAccountOrDebitCardAvailable: 'Por favor, aΓ±ade una cuenta bancaria para depΓ³sitos o una tarjeta de dΓ©bito', + address: 'Por favor, introduce una direcciΓ³n vΓ‘lida.', + dob: 'Por favor, selecciona una fecha de nacimiento vΓ‘lida.', + age: 'Debe ser mayor de 18 aΓ±os.', + ssnLast4: 'Por favor, introduce los ΓΊltimos 4 dΓgitos del nΓΊmero de seguridad social.', + firstName: 'Por favor, introduce el nombre.', + lastName: 'Por favor, introduce los apellidos.', + noDefaultDepositAccountOrDebitCardAvailable: 'Por favor, aΓ±ade una cuenta bancaria para depΓ³sitos o una tarjeta de dΓ©bito.', validationAmounts: 'Los importes de validaciΓ³n que introduciste son incorrectos. Por favor, comprueba tu cuenta bancaria e intΓ©ntalo de nuevo.', }, }, @@ -2305,6 +2303,7 @@ export default { accountsPayable: 'Cuentas por pagar', accountsPayableDescription: 'Elige dΓ³nde crear las facturas de proveedores.', bankAccount: 'Cuenta bancaria', + notConfigured: 'No configurado', bankAccountDescription: 'Elige desde dΓ³nde enviar los cheques.', creditCardAccount: 'Cuenta de la tarjeta de crΓ©dito', companyCardsLocationEnabledDescription: @@ -2842,6 +2841,44 @@ export default { companyCards: { addCompanyCards: 'Agregar tarjetas de empresa', selectCardFeed: 'Seleccionar feed de tarjetas', + addCardFeed: 'AΓ±adir alimentaciΓ³n de tarjeta', + addNewCard: { + cardProviders: { + amex: 'Tarjetas de empresa American Express', + mastercard: 'Tarjetas comerciales Mastercard', + visa: 'Tarjetas comerciales Visa', + }, + yourCardProvider: `ΒΏQuiΓ©n es su proveedor de tarjetas?`, + enableFeed: { + title: (provider: string) => `Habilita tu feed ${provider}`, + heading: + 'Tenemos una integraciΓ³n directa con el emisor de su tarjeta y podemos importar los datos de sus transacciones a Expensify de forma rΓ‘pida y precisa.\n\nPara empezar, simplemente:', + visa: `1. Visite [este artΓculo de ayuda](${CONST.COMPANY_CARDS_HELP}) para obtener instrucciones detalladas sobre cΓ³mo configurar sus tarjetas comerciales Visa.\n\n2. [PΓ³ngase en contacto con su banco](${CONST.COMPANY_CARDS_HELP}) para comprobar que admiten un feed personalizado para su programa, y pΓdales que lo activen.\n\n3. *Una vez que el feed estΓ© habilitado y tengas sus datos, pasa a la siguiente pantalla.*`, + amex: `1. Visite [este artΓculo de ayuda](${CONST.COMPANY_CARDS_HELP}) para saber si American Express puede habilitar un feed personalizado para su programa.\n\n2. Una vez activada la alimentaciΓ³n, Amex le enviarΓ‘ una carta de producciΓ³n.\n\n3. *Una vez que tenga la informaciΓ³n de alimentaciΓ³n, continΓΊe con la siguiente pantalla.*`, + mastercard: `1. Visite [este artΓculo de ayuda](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS}) para obtener instrucciones detalladas sobre cΓ³mo configurar sus tarjetas comerciales Mastercard.\n\n 2. [PΓ³ngase en contacto con su banco](${CONST.COMPANY_CARDS_HELP}) para verificar que admiten un feed personalizado para su programa, y pΓdales que lo habiliten.\n\n3. *Una vez que el feed estΓ© habilitado y tengas sus datos, pasa a la siguiente pantalla.*`, + }, + whatBankIssuesCard: 'ΒΏQuΓ© banco emite estas tarjetas?', + enterNameOfBank: 'Introduzca el nombre del banco', + feedDetails: { + visa: { + title: 'ΒΏCuΓ‘les son los datos de alimentaciΓ³n de Visa?', + processorLabel: 'ID del procesador', + bankLabel: 'IdentificaciΓ³n de la instituciΓ³n financiera (banco)', + companyLabel: 'Empresa ID', + }, + amex: { + title: `ΒΏCuΓ‘l es el nombre del archivo de entrega de Amex?`, + fileNameLabel: 'Nombre del archivo de entrega', + }, + mastercard: { + title: `ΒΏCuΓ‘l es el identificador de distribuciΓ³n de Mastercard?`, + distributionLabel: 'ID de distribuciΓ³n', + }, + }, + error: { + pleaseSelectProvider: 'Seleccione un proveedor de tarjetas antes de continuar.', + }, + }, assignCard: 'Asignar tarjeta', cardNumber: 'NΓΊmero de la tarjeta', customFeed: 'Fuente personalizada', @@ -2982,18 +3019,22 @@ export default { title: 'Integrar', subtitle: 'Conecta Expensify a otros productos financieros populares.', }, + distanceRates: { + title: 'Tasas de distancia', + subtitle: 'AΓ±ade, actualiza y haz cumplir las tasas.', + }, expensifyCard: { title: 'Tarjeta Expensify', - subtitle: 'ObtΓ©n informaciΓ³n y control sobre tus gastos', + subtitle: 'ObtΓ©n informaciΓ³n y control sobre tus gastos.', disableCardTitle: 'Deshabilitar la Tarjeta Expensify', disableCardPrompt: 'No puedes deshabilitar la Tarjeta Expensify porque ya estΓ‘ en uso. Por favor, contacta con Concierge para conocer los pasos a seguir.', disableCardButton: 'Chatear con Concierge', feed: { title: 'Consigue la Tarjeta Expensify', - subTitle: 'Optimiza tu negocio con la Tarjeta Expensify', + subTitle: 'Optimiza tu negocio con la Tarjeta Expensify.', features: { - cashBack: 'Hasta un 2% de devoluciΓ³n en cada compra en Estadios Unidos', - unlimited: 'Emitir un nΓΊmero ilimitado de tarjetas virtuales', + cashBack: 'DevoluciΓ³n de dinero en cada compra en Estados Unidos', + unlimited: 'Un nΓΊmero ilimitado de tarjetas virtuales', spend: 'Controles de gastos y lΓmites personalizados', }, ctaTitle: 'Emitir nueva tarjeta', @@ -3035,10 +3076,6 @@ export default { emptyAddedFeedTitle: 'Asignar tarjetas de empresa', emptyAddedFeedDescription: 'Comienza asignando tu primera tarjeta a un miembro.', }, - distanceRates: { - title: 'Tasas de distancia', - subtitle: 'AΓ±ade, actualiza y haz cumplir las tasas.', - }, workflows: { title: 'Flujos de trabajo', subtitle: 'Configura cΓ³mo se aprueba y paga los gastos.', @@ -3215,8 +3252,9 @@ export default { removeMembersWarningPrompt: ({memberName, ownerName}: RemoveMembersWarningPrompt) => `${memberName} es un aprobador en este espacio de trabajo. Cuando lo elimine de este espacio de trabajo, los sustituiremos en el flujo de trabajo de aprobaciΓ³n por el propietario del espacio de trabajo, ${ownerName}`, removeMembersTitle: 'Eliminar miembros', - removeMemberButtonTitle: 'Quitar del espacio de trabajo', - removeMemberGroupButtonTitle: 'Quitar del grupo', + removeWorkspaceMemberButtonTitle: 'Eliminar del espacio de trabajo', + removeGroupMemberButtonTitle: 'Eliminar del grupo', + removeRoomMemberButtonTitle: 'Eliminar del chat', removeMemberPrompt: ({memberName}: {memberName: string}) => `ΒΏEstΓ‘s seguro de que deseas eliminar a ${memberName}?`, removeMemberTitle: 'Eliminar miembro', transferOwner: 'Transferir la propiedad', @@ -3475,7 +3513,7 @@ export default { fixedAmountDescription: 'Gasta hasta una determinada cantidad una vez', setLimit: 'Establecer un lΓmite', giveItName: 'Dale un nombre', - giveItNameInstruction: 'Hazlo lo suficientemente ΓΊnico como para distinguirlo de los demΓ‘s. Los casos de uso especΓficos son aΓΊn mejores.', + giveItNameInstruction: 'Hazlo lo suficientemente ΓΊnico para distinguirla de otras tarjetas. Β‘Los casos de uso especΓficos son aΓΊn mejores!', cardName: 'Nombre de la tarjeta', letsDoubleCheck: 'Vuelve a comprobar que todo parece correcto. ', willBeReady: 'Esta tarjeta estarΓ‘ lista para su uso inmediato.', @@ -3616,6 +3654,8 @@ export default { continueWithSetup: 'Continuar con la configuraciΓ³n', youreAlmostDone: 'Casi has acabado de configurar tu cuenta bancaria, que te permitirΓ‘ emitir tarjetas corporativas, reembolsar gastos y cobrar pagar facturas.', streamlinePayments: 'Optimiza pagos', + connectBankAccountNote: 'Nota: No se pueden usar cuentas bancarias personales para realizar pagos en los espacios de trabajo.', + oneMoreThing: 'Β‘Una cosa mΓ‘s!', allSet: 'Β‘Todo listo!', accountDescriptionWithCards: 'Esta cuenta bancaria se utilizarΓ‘ para emitir tarjetas corporativas, reembolsar gastos y cobrar y pagar facturas.', letsFinishInChat: 'Β‘Continuemos en el chat!', @@ -3785,8 +3825,66 @@ export default { eReceiptsHintLink: 'para la mayorΓa de las transacciones en USD', }, expenseReportRules: { + examples: 'Ejemplos:', title: 'Informes de gastos', subtitle: 'Automatiza el cumplimiento, la aprobaciΓ³n y el pago de los informes de gastos.', + customReportNamesTitle: 'Nombres personalizados de informes', + customReportNamesSubtitle: 'Crea nombres personalizados usando nuestras fΓ³rmulas variadas.', + customNameTitle: 'Nombre personalizado', + customNameDescription: 'Elige un nombre personalizado para los informes de gastos usando nuestras ', + customNameDescriptionLink: 'fΓ³rmulas variadas', + customNameInputLabel: 'Nombre', + customNameEmailPhoneExample: 'Correo electrΓ³nico o telΓ©fono del miembro: {report:submit:from}', + customNameStartDateExample: 'Fecha de inicio del informe: {report:startdate}', + customNameWorkspaceNameExample: 'Nombre del espacio de trabajo: {report:policyname}', + customNameReportIDExample: 'ID del informe: {report:id}', + customNameTotalExample: 'Total: {report:total}.', + preventMembersFromChangingCustomNamesTitle: 'Evitar que los miembros cambien los nombres personalizados de los informes', + preventSelfApprovalsTitle: 'Evitar autoaprobaciones', + preventSelfApprovalsSubtitle: 'Evita que los miembros del espacio de trabajo aprueben sus propios informes de gastos.', + autoApproveCompliantReportsTitle: 'AprobaciΓ³n automΓ‘tica de informes conformes', + autoApproveCompliantReportsSubtitle: 'Configura quΓ© informes de gastos pueden aprobarse de forma automΓ‘tica.', + autoApproveReportsUnderTitle: 'Aprobar automΓ‘ticamente informes por debajo de', + autoApproveReportsUnderDescription: 'Los informes de gastos totalmente conformes por debajo de esta cantidad se aprobarΓ‘n automΓ‘ticamente.', + randomReportAuditTitle: 'AuditorΓa aleatoria de informes', + randomReportAuditDescription: 'Requiere que algunos informes sean aprobados manualmente, incluso si son elegibles para la aprobaciΓ³n automΓ‘tica.', + autoPayApprovedReportsTitle: 'Pago automΓ‘tico de informes aprobados', + autoPayApprovedReportsSubtitle: 'Configura quΓ© informes de gastos pueden pagarse de forma automΓ‘tica.', + autoPayApprovedReportsLimitError: (currency?: string) => `Por favor, introduce un monto menor a ${currency ?? ''}20,000`, + autoPayApprovedReportsLockedSubtitle: 'Ve a mΓ‘s funciones y habilita flujos de trabajo, luego agrega pagos para desbloquear esta funciΓ³n.', + autoPayReportsUnderTitle: 'Pagar automΓ‘ticamente informes por debajo de', + autoPayReportsUnderDescription: 'Los informes de gastos totalmente conformes por debajo de esta cantidad se pagarΓ‘n automΓ‘ticamente.', + unlockFeatureGoToSubtitle: 'Ir a', + unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `y habilita flujos de trabajo, luego agrega ${featureName} para desbloquear esta funciΓ³n.`, + }, + categoryRules: { + title: 'Reglas de categorΓa', + approver: 'Aprobador', + requireDescription: 'Requerir descripciΓ³n', + descriptionHint: 'Sugerencia de descripciΓ³n', + descriptionHintDescription: (categoryName: string) => + `Recuerda a los empleados que deben proporcionar informaciΓ³n adicional para los gastos de β${categoryName}β. Esta sugerencia aparece en el campo de descripciΓ³n en los gastos.`, + descriptionHintLabel: 'Sugerencia', + descriptionHintSubtitle: 'Consejo: Β‘Cuanto mΓ‘s corta, mejor!', + maxAmount: 'Importe mΓ‘ximo', + flagAmountsOver: 'SeΓ±ala importes superiores a', + flagAmountsOverDescription: (categoryName: string) => `Aplica a la categorΓa β${categoryName}β.`, + flagAmountsOverSubtitle: 'Esto anula el importe mΓ‘ximo para todos los gastos.', + expenseLimitTypes: { + expense: 'Gasto individual', + expenseSubtitle: 'SeΓ±ala importes de gastos por categorΓa. Esta regla anula la regla general del espacio de trabajo para el importe mΓ‘ximo de gastos.', + daily: 'Total por categorΓa', + dailySubtitle: 'Marcar el gasto total por categorΓa en cada informe de gastos.', + }, + requireReceiptsOver: 'Requerir recibos para importes superiores a', + requireReceiptsOverList: { + default: (defaultAmount: string) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Predeterminado`, + never: 'Nunca requerir recibos', + always: 'Requerir recibos siempre', + }, + defaultTaxRate: 'Tasa de impuesto predeterminada', + goTo: 'Ve a', + andEnableWorkflows: 'y habilita los flujos de trabajo, luego aΓ±ade aprobaciones para desbloquear esta funciΓ³n.', }, }, }, @@ -3845,9 +3943,23 @@ export default { }, workspaceActions: { renamedWorkspaceNameAction: ({oldName, newName}) => `actualizΓ³ el nombre de este espacio de trabajo de ${oldName} a ${newName}`, + removedFromApprovalWorkflow: ({submittersNames}: {submittersNames: string[]}) => { + let joinedNames = ''; + if (submittersNames.length === 1) { + joinedNames = submittersNames[0]; + } else if (submittersNames.length === 2) { + joinedNames = submittersNames.join(' y '); + } else if (submittersNames.length > 2) { + joinedNames = `${submittersNames.slice(0, submittersNames.length - 1).join(', ')} y ${submittersNames[submittersNames.length - 1]}`; + } + const workflowWord = Str.pluralize('del flujo', 'de los flujos', submittersNames.length); + const chatWord = Str.pluralize('del chat', 'de los chats', submittersNames.length); + return `te eliminΓ³ ${workflowWord} de trabajo de aprobaciones y ${chatWord} del espacio de trabajo de ${joinedNames}. Los informes enviados anteriormente seguirΓ‘n estando disponibles para su aprobaciΓ³n en tu bandeja de entrada.`; + }, }, roomMembersPage: { - memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro a la sala de chat, por favor, utiliza el botΓ³n invitar que estΓ‘ mΓ‘s arriba.', + memberNotFound: 'Miembro no encontrado.', + useInviteButton: 'Para invitar a un nuevo miembro al chat, por favor, utiliza el botΓ³n invitar que estΓ‘ mΓ‘s arriba.', notAuthorized: `No tienes acceso a esta pΓ‘gina. Si estΓ‘s intentando unirte a esta sala, pide a un miembro de la sala que te aΓ±ada. ΒΏNecesitas algo mΓ‘s? ComunΓcate con ${CONST.EMAIL.CONCIERGE}`, removeMembersPrompt: 'ΒΏEstΓ‘s seguro de que quieres eliminar a los miembros seleccionados de la sala de chat?', error: { @@ -3935,13 +4047,16 @@ export default { keyword: 'Palabra clave', hasKeywords: 'Tiene palabras clave', currency: 'Divisa', - has: 'Tiene', link: 'Enlace', + pinned: 'Fijado', + unread: 'No leΓdo', amount: { lessThan: (amount?: string) => `Menos de ${amount ?? ''}`, greaterThan: (amount?: string) => `MΓ‘s que ${amount ?? ''}`, between: (greaterThan: string, lessThan: string) => `Entre ${greaterThan} y ${lessThan}`, }, + current: 'Actual', + past: 'Anterior', }, expenseType: 'Tipo de gasto', }, @@ -5106,7 +5221,12 @@ export default { }, delegate: { switchAccount: 'Cambiar de cuenta:', - role: (role: DelegateRole): string => { + copilotDelegatedAccess: 'Copilot: Acceso delegado', + copilotDelegatedAccessDescription: 'Permitir que otros miembros accedan a tu cuenta.', + addCopilot: 'Agregar copiloto', + membersCanAccessYourAccount: 'Estos miembros pueden acceder a tu cuenta:', + youCanAccessTheseAccounts: 'Puedes acceder a estas cuentas a travΓ©s del conmutador de cuentas:', + role: (role?: string): string => { switch (role) { case CONST.DELEGATE_ROLE.ALL: return 'Completo'; @@ -5117,5 +5237,24 @@ export default { } }, genericError: 'Β‘Ups! Ha ocurrido un error. Por favor, intΓ©ntalo de nuevo.', + accessLevel: 'Nivel de acceso', + confirmCopilot: 'Confirma tu copiloto a continuaciΓ³n.', + accessLevelDescription: 'Elige un nivel de acceso a continuaciΓ³n. Tanto el acceso Completo como el Limitado permiten a los copilotos ver todas las conversaciones y gastos.', + roleDescription: (role?: string): string => { + switch (role) { + case CONST.DELEGATE_ROLE.ALL: + return 'Permite a otro miembro realizar todas las acciones en tu cuenta, en tu nombre. Incluye chat, presentaciones, aprobaciones, pagos, actualizaciones de configuraciΓ³n y mΓ‘s.'; + case CONST.DELEGATE_ROLE.SUBMITTER: + return 'Permite a otro miembro realizar la mayorΓa de las acciones en tu cuenta, en tu nombre. Excluye aprobaciones, pagos, rechazos y retenciones.'; + default: + return ''; + } + }, + makeSureItIsYou: 'Vamos a asegurarnos de que eres tΓΊ', + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Por favor, introduce el cΓ³digo mΓ‘gico enviado a ${contactMethod} para agregar un copiloto.`, + notAllowed: 'No tan rΓ‘pido...', + notAllowedMessageStart: ({accountOwnerEmail}: AccountOwnerParams) => `No tienes permiso para realizar esta acciΓ³n para ${accountOwnerEmail}`, + notAllowedMessageHyperLinked: ' copiloto con acceso', + notAllowedMessageEnd: ' limitado', }, } satisfies EnglishTranslation; diff --git a/src/languages/types.ts b/src/languages/types.ts index f953cb17255b..ca2b70f6ac61 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -95,6 +95,10 @@ type ReportArchiveReasonsPolicyDeletedParams = { policyName: string; }; +type ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams = { + policyName: string; +}; + type RequestCountParams = { count: number; scanningReceipts: number; @@ -103,7 +107,6 @@ type RequestCountParams = { type SettleExpensifyCardParams = { formattedAmount: string; - available?: boolean; }; type RequestAmountParams = {amount: string}; @@ -316,6 +319,8 @@ type ChangeTypeParams = {oldType: string; newType: string}; type DelegateSubmitParams = {delegateUser: string; originalManager: string}; +type AccountOwnerParams = {accountOwnerEmail: string}; + type ExportedToIntegrationParams = {label: string; markedManually?: boolean; inProgress?: boolean; lastModified?: string}; type IntegrationsMessageParams = { @@ -424,6 +429,7 @@ export type { ReportArchiveReasonsClosedParams, ReportArchiveReasonsMergedParams, ReportArchiveReasonsPolicyDeletedParams, + ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams, ReportArchiveReasonsRemovedFromPolicyParams, RequestAmountParams, RequestCountParams, @@ -480,6 +486,7 @@ export type { ChangeTypeParams, ExportedToIntegrationParams, DelegateSubmitParams, + AccountOwnerParams, IntegrationsMessageParams, MarkedReimbursedParams, MarkReimbursedFromIntegrationParams, diff --git a/src/libs/API/parameters/AddDelegateParams.ts b/src/libs/API/parameters/AddDelegateParams.ts new file mode 100644 index 000000000000..3f5fcb2a9082 --- /dev/null +++ b/src/libs/API/parameters/AddDelegateParams.ts @@ -0,0 +1,9 @@ +import type {DelegateRole} from '@src/types/onyx/Account'; + +type AddDelegateParams = { + delegate: string; + role: DelegateRole; + validateCode: string; +}; + +export default AddDelegateParams; diff --git a/src/libs/API/parameters/CompleteGuidedSetupParams.ts b/src/libs/API/parameters/CompleteGuidedSetupParams.ts index 8e1273ac6053..0b2c0b66ef0a 100644 --- a/src/libs/API/parameters/CompleteGuidedSetupParams.ts +++ b/src/libs/API/parameters/CompleteGuidedSetupParams.ts @@ -6,6 +6,7 @@ type CompleteGuidedSetupParams = { actorAccountID: number; guidedSetupData: string; engagementChoice: OnboardingPurposeType; + paymentSelected?: string; }; export default CompleteGuidedSetupParams; diff --git a/src/libs/API/parameters/CreateExpensifyCardParams.ts b/src/libs/API/parameters/CreateExpensifyCardParams.ts index 1425a4ba9b99..f6aa9eb8e512 100644 --- a/src/libs/API/parameters/CreateExpensifyCardParams.ts +++ b/src/libs/API/parameters/CreateExpensifyCardParams.ts @@ -5,6 +5,7 @@ type CreateExpensifyCardParams = { limitType: string; cardTitle: string; feedCountry: string; + domainAccountID: number; }; export default CreateExpensifyCardParams; diff --git a/src/libs/API/parameters/EnablePolicyAutoApprovalOptions.ts b/src/libs/API/parameters/EnablePolicyAutoApprovalOptions.ts new file mode 100644 index 000000000000..4c80b1bf6d7d --- /dev/null +++ b/src/libs/API/parameters/EnablePolicyAutoApprovalOptions.ts @@ -0,0 +1,6 @@ +type EnablePolicyAutoApprovalOptionsParams = { + policyID: string; + enabled: boolean; +}; + +export default EnablePolicyAutoApprovalOptionsParams; diff --git a/src/libs/API/parameters/EnablePolicyAutoReimbursementLimit.ts b/src/libs/API/parameters/EnablePolicyAutoReimbursementLimit.ts new file mode 100644 index 000000000000..acbc2efade41 --- /dev/null +++ b/src/libs/API/parameters/EnablePolicyAutoReimbursementLimit.ts @@ -0,0 +1,6 @@ +type EnablePolicyAutoReimbursementLimitParams = { + policyID: string; + enabled: boolean; +}; + +export default EnablePolicyAutoReimbursementLimitParams; diff --git a/src/libs/API/parameters/EnablePolicyDefaultReportTitle.ts b/src/libs/API/parameters/EnablePolicyDefaultReportTitle.ts new file mode 100644 index 000000000000..2852640ac70a --- /dev/null +++ b/src/libs/API/parameters/EnablePolicyDefaultReportTitle.ts @@ -0,0 +1,6 @@ +type EnablePolicyDefaultReportTitleParams = { + policyID: string; + enable: boolean; +}; + +export default EnablePolicyDefaultReportTitleParams; diff --git a/src/libs/API/parameters/ExportCategoriesSpreadsheet.ts b/src/libs/API/parameters/ExportCategoriesSpreadsheet.ts new file mode 100644 index 000000000000..e62cbe684cf6 --- /dev/null +++ b/src/libs/API/parameters/ExportCategoriesSpreadsheet.ts @@ -0,0 +1,6 @@ +type ExportCategoriesSpreadsheetParams = { + /** ID of the policy */ + policyID: string; +}; + +export default ExportCategoriesSpreadsheetParams; diff --git a/src/libs/API/parameters/PayInvoiceParams.ts b/src/libs/API/parameters/PayInvoiceParams.ts index a6b9746d87bc..7f80e5d20c4c 100644 --- a/src/libs/API/parameters/PayInvoiceParams.ts +++ b/src/libs/API/parameters/PayInvoiceParams.ts @@ -1,6 +1,7 @@ import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type CreateWorkspaceParams from './CreateWorkspaceParams'; -type PayInvoiceParams = { +type PayInvoiceParams = Partial<CreateWorkspaceParams> & { reportID: string; reportActionID: string; paymentMethodType: PaymentMethodType; diff --git a/src/libs/API/parameters/RemovePolicyCategoryReceiptsRequiredParams.ts b/src/libs/API/parameters/RemovePolicyCategoryReceiptsRequiredParams.ts new file mode 100644 index 000000000000..83e62db59811 --- /dev/null +++ b/src/libs/API/parameters/RemovePolicyCategoryReceiptsRequiredParams.ts @@ -0,0 +1,6 @@ +type RemovePolicyCategoryReceiptsRequiredParams = { + policyID: string; + categoryName: string; +}; + +export default RemovePolicyCategoryReceiptsRequiredParams; diff --git a/src/libs/API/parameters/RevealExpensifyCardDetailsParams.ts b/src/libs/API/parameters/RevealExpensifyCardDetailsParams.ts index ec698fc85269..352875cf17dd 100644 --- a/src/libs/API/parameters/RevealExpensifyCardDetailsParams.ts +++ b/src/libs/API/parameters/RevealExpensifyCardDetailsParams.ts @@ -1,3 +1,3 @@ -type RevealExpensifyCardDetailsParams = {cardID: number}; +type RevealExpensifyCardDetailsParams = {cardID: number; validateCode: string}; export default RevealExpensifyCardDetailsParams; diff --git a/src/libs/API/parameters/SetPolicyAutoReimbursementLimit.ts b/src/libs/API/parameters/SetPolicyAutoReimbursementLimit.ts new file mode 100644 index 000000000000..7c6a721e03b0 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyAutoReimbursementLimit.ts @@ -0,0 +1,6 @@ +type SetPolicyAutoReimbursementLimitParams = { + policyID: string; + autoReimbursement: {limit: number}; +}; + +export default SetPolicyAutoReimbursementLimitParams; diff --git a/src/libs/API/parameters/SetPolicyAutomaticApprovalLimit.ts b/src/libs/API/parameters/SetPolicyAutomaticApprovalLimit.ts new file mode 100644 index 000000000000..bc9c07570f97 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyAutomaticApprovalLimit.ts @@ -0,0 +1,6 @@ +type SetPolicyAutomaticApprovalLimitParams = { + policyID: string; + limit: number; +}; + +export default SetPolicyAutomaticApprovalLimitParams; diff --git a/src/libs/API/parameters/SetPolicyAutomaticApprovalRate.ts b/src/libs/API/parameters/SetPolicyAutomaticApprovalRate.ts new file mode 100644 index 000000000000..14e331fcb27c --- /dev/null +++ b/src/libs/API/parameters/SetPolicyAutomaticApprovalRate.ts @@ -0,0 +1,6 @@ +type SetPolicyAutomaticApprovalRateParams = { + policyID: string; + auditRate: number; +}; + +export default SetPolicyAutomaticApprovalRateParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryApproverParams.ts b/src/libs/API/parameters/SetPolicyCategoryApproverParams.ts new file mode 100644 index 000000000000..197fdaf59df6 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryApproverParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryApproverParams = { + policyID: string; + categoryName: string; + approver: string; +}; + +export default SetPolicyCategoryApproverParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryDescriptionRequiredParams.ts b/src/libs/API/parameters/SetPolicyCategoryDescriptionRequiredParams.ts new file mode 100644 index 000000000000..6a1748ff9ad1 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryDescriptionRequiredParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryDescriptionRequiredParams = { + policyID: string; + categoryName: string; + areCommentsRequired: boolean; +}; + +export default SetPolicyCategoryDescriptionRequiredParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryMaxAmountParams.ts b/src/libs/API/parameters/SetPolicyCategoryMaxAmountParams.ts new file mode 100644 index 000000000000..6132f0a69b1b --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryMaxAmountParams.ts @@ -0,0 +1,10 @@ +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; + +type SetPolicyCategoryMaxAmountParams = { + policyID: string; + categoryName: string; + maxExpenseAmount: number | null; + expenseLimitType: PolicyCategoryExpenseLimitType; +}; + +export default SetPolicyCategoryMaxAmountParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryReceiptsRequiredParams.ts b/src/libs/API/parameters/SetPolicyCategoryReceiptsRequiredParams.ts new file mode 100644 index 000000000000..fe7c15bd8eff --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryReceiptsRequiredParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryReceiptsRequiredParams = { + policyID: string; + categoryName: string; + maxExpenseAmountNoReceipt: number; +}; + +export default SetPolicyCategoryReceiptsRequiredParams; diff --git a/src/libs/API/parameters/SetPolicyCategoryTaxParams.ts b/src/libs/API/parameters/SetPolicyCategoryTaxParams.ts new file mode 100644 index 000000000000..94a0a6025916 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryTaxParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryTaxParams = { + policyID: string; + categoryName: string; + taxID: string; +}; + +export default SetPolicyCategoryTaxParams; diff --git a/src/libs/API/parameters/SetPolicyDefaultReportTitle.ts b/src/libs/API/parameters/SetPolicyDefaultReportTitle.ts new file mode 100644 index 000000000000..e35dc53e7c7c --- /dev/null +++ b/src/libs/API/parameters/SetPolicyDefaultReportTitle.ts @@ -0,0 +1,6 @@ +type SetPolicyDefaultReportTitleParams = { + policyID: string; + value: string; +}; + +export default SetPolicyDefaultReportTitleParams; diff --git a/src/libs/API/parameters/SetPolicyPreventMemberCreatedTitleParams.ts b/src/libs/API/parameters/SetPolicyPreventMemberCreatedTitleParams.ts new file mode 100644 index 000000000000..92f44aacff41 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyPreventMemberCreatedTitleParams.ts @@ -0,0 +1,6 @@ +type SetPolicyPreventMemberCreatedTitleParams = { + policyID: string; + enforced: boolean; +}; + +export default SetPolicyPreventMemberCreatedTitleParams; diff --git a/src/libs/API/parameters/SetPolicyPreventSelfApproval.ts b/src/libs/API/parameters/SetPolicyPreventSelfApproval.ts new file mode 100644 index 000000000000..7b8398905fee --- /dev/null +++ b/src/libs/API/parameters/SetPolicyPreventSelfApproval.ts @@ -0,0 +1,6 @@ +type SetPolicyPreventSelfApprovalParams = { + policyID: string; + preventSelfApproval: boolean; +}; + +export default SetPolicyPreventSelfApprovalParams; diff --git a/src/libs/API/parameters/SetWorkspaceCategoryDescriptionHintParams.ts b/src/libs/API/parameters/SetWorkspaceCategoryDescriptionHintParams.ts new file mode 100644 index 000000000000..d1c3b36975cb --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceCategoryDescriptionHintParams.ts @@ -0,0 +1,7 @@ +type SetWorkspaceCategoryDescriptionHintParams = { + policyID: string; + categoryName: string; + commentHint: string; +}; + +export default SetWorkspaceCategoryDescriptionHintParams; diff --git a/src/libs/API/parameters/TrackExpenseParams.ts b/src/libs/API/parameters/TrackExpenseParams.ts index d5e1de2e625b..3a7d0df6736c 100644 --- a/src/libs/API/parameters/TrackExpenseParams.ts +++ b/src/libs/API/parameters/TrackExpenseParams.ts @@ -27,6 +27,7 @@ type TrackExpenseParams = { createdReportActionIDForThread: string; waypoints?: string; actionableWhisperReportActionID?: string; + customUnitRateID?: string; }; export default TrackExpenseParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index b58b35e752a1..772ca7b8035d 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -276,6 +276,15 @@ export type {CreateWorkspaceApprovalParams, UpdateWorkspaceApprovalParams, Remov export type {default as StartIssueNewCardFlowParams} from './StartIssueNewCardFlowParams'; export type {default as ConnectAsDelegateParams} from './ConnectAsDelegateParams'; export type {default as SetPolicyRulesEnabledParams} from './SetPolicyRulesEnabledParams'; +export type {default as SetPolicyDefaultReportTitleParams} from './SetPolicyDefaultReportTitle'; +export type {default as SetPolicyPreventSelfApprovalParams} from './SetPolicyPreventSelfApproval'; +export type {default as SetPolicyAutomaticApprovalLimitParams} from './SetPolicyAutomaticApprovalLimit'; +export type {default as SetPolicyAutomaticApprovalRateParams} from './SetPolicyAutomaticApprovalRate'; +export type {default as SetPolicyPreventMemberCreatedTitleParams} from './SetPolicyPreventMemberCreatedTitleParams'; +export type {default as SetPolicyAutoReimbursementLimitParams} from './SetPolicyAutoReimbursementLimit'; +export type {default as EnablePolicyAutoReimbursementLimitParams} from './EnablePolicyAutoReimbursementLimit'; +export type {default as EnablePolicyAutoApprovalOptionsParams} from './EnablePolicyAutoApprovalOptions'; +export type {default as EnablePolicyDefaultReportTitleParams} from './EnablePolicyDefaultReportTitle'; export type {default as SetPolicyExpenseMaxAmountNoReceipt} from './SetPolicyExpenseMaxAmountNoReceipt'; export type {default as SetPolicyExpenseMaxAmount} from './SetPolicyExpenseMaxAmount'; export type {default as SetPolicyExpenseMaxAge} from './SetPolicyExpenseMaxAge'; @@ -285,13 +294,22 @@ export type {default as SetWorkspaceEReceiptsEnabled} from './SetWorkspaceERecei export type {default as ConfigureExpensifyCardsForPolicyParams} from './ConfigureExpensifyCardsForPolicyParams'; export type {default as CreateExpensifyCardParams} from './CreateExpensifyCardParams'; export type {default as UpdateExpensifyCardTitleParams} from './UpdateExpensifyCardTitleParams'; +export type {default as AddDelegateParams} from './AddDelegateParams'; export type {default as OpenCardDetailsPageParams} from './OpenCardDetailsPageParams'; +export type {default as SetPolicyCategoryDescriptionRequiredParams} from './SetPolicyCategoryDescriptionRequiredParams'; +export type {default as SetPolicyCategoryApproverParams} from './SetPolicyCategoryApproverParams'; +export type {default as SetWorkspaceCategoryDescriptionHintParams} from './SetWorkspaceCategoryDescriptionHintParams'; +export type {default as SetPolicyCategoryTaxParams} from './SetPolicyCategoryTaxParams'; +export type {default as SetPolicyCategoryMaxAmountParams} from './SetPolicyCategoryMaxAmountParams'; export type {default as EnablePolicyCompanyCardsParams} from './EnablePolicyCompanyCardsParams'; export type {default as ToggleCardContinuousReconciliationParams} from './ToggleCardContinuousReconciliationParams'; export type {default as CardDeactivateParams} from './CardDeactivateParams'; export type {default as UpdateExpensifyCardLimitTypeParams} from './UpdateExpensifyCardLimitTypeParams'; +export type {default as SetPolicyCategoryReceiptsRequiredParams} from './SetPolicyCategoryReceiptsRequiredParams'; +export type {default as RemovePolicyCategoryReceiptsRequiredParams} from './RemovePolicyCategoryReceiptsRequiredParams'; export type {default as UpdateQuickbooksOnlineAutoCreateVendorParams} from './UpdateQuickbooksOnlineAutoCreateVendorParams'; export type {default as ImportCategoriesSpreadsheetParams} from './ImportCategoriesSpreadsheet'; +export type {default as ExportCategoriesSpreadsheetParams} from './ExportCategoriesSpreadsheet'; export type {default as UpdateXeroGenericTypeParams} from './UpdateXeroGenericTypeParams'; export type {default as UpdateCardSettlementFrequencyParams} from './UpdateCardSettlementFrequencyParams'; export type {default as UpdateCardSettlementAccountParams} from './UpdateCardSettlementAccountParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index bc2bc5f342af..e631fd806789 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -14,6 +14,15 @@ const WRITE_COMMANDS = { SET_WORKSPACE_APPROVAL_MODE: 'SetWorkspaceApprovalMode', SET_WORKSPACE_PAYER: 'SetWorkspacePayer', SET_WORKSPACE_REIMBURSEMENT: 'SetWorkspaceReimbursement', + SET_POLICY_DEFAULT_REPORT_TITLE: 'SetPolicyDefaultReportTitle', + SET_POLICY_PREVENT_MEMBER_CREATED_TITLE: 'SetPolicyPreventMemberCreatedTitle', + SET_POLICY_PREVENT_SELF_APPROVAL: 'SetPolicyPreventSelfApproval', + SET_POLICY_AUTOMATIC_APPROVAL_LIMIT: 'SetPolicyAutomaticApprovalLimit', + SET_POLICY_AUTOMATIC_APPROVAL_RATE: 'SetPolicyAutomaticApprovalRate', + SET_POLICY_AUTO_REIMBURSEMENT_LIMIT: 'SetPolicyAutoReimbursementLimit', + ENABLE_POLICY_AUTO_REIMBURSEMENT_LIMIT: 'EnablePolicyAutoReimbursementLimit', + ENABLE_POLICY_AUTO_APPROVAL_OPTIONS: 'EnablePolicyAutoApprovalOptions', + ENABLE_POLICY_DEFAULT_REPORT_TITLE: 'EnablePolicyDefaultReportTitle', SET_WORKSPACE_DEFAULT_SPEND_CATEGORY: 'SetPolicyDefaultSpendCategory', DISMISS_REFERRAL_BANNER: 'DismissReferralBanner', UPDATE_PREFERRED_LOCALE: 'UpdatePreferredLocale', @@ -133,7 +142,8 @@ const WRITE_COMMANDS = { SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled', SET_POLICY_TAGS_ENABLED: 'SetPolicyTagsEnabled', CREATE_WORKSPACE_CATEGORIES: 'CreateWorkspaceCategories', - IMPORT_CATEGORIES_SREADSHEET: 'ImportCategoriesSpreadsheet', + IMPORT_CATEGORIES_SPREADSHEET: 'ImportCategoriesSpreadsheet', + EXPORT_CATEGORIES_CSV: 'ExportCategoriesCSV', RENAME_WORKSPACE_CATEGORY: 'RenameWorkspaceCategory', CREATE_POLICY_TAG: 'CreatePolicyTag', RENAME_POLICY_TAG: 'RenamePolicyTag', @@ -212,6 +222,13 @@ const WRITE_COMMANDS = { SET_POLICY_BILLABLE_MODE: ' SetPolicyBillableMode', DISABLE_POLICY_BILLABLE_MODE: 'DisablePolicyBillableExpenses', SET_WORKSPACE_ERECEIPTS_ENABLED: 'SetWorkspaceEReceiptsEnabled', + SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED: 'SetPolicyCategoryDescriptionRequired', + SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT: 'SetWorkspaceCategoryDescriptionHint', + SET_POLICY_CATEGORY_RECEIPTS_REQUIRED: 'SetPolicyCategoryReceiptsRequired', + REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED: 'RemoveWorkspaceCategoryReceiptsRequired', + SET_POLICY_CATEGORY_MAX_AMOUNT: 'SetPolicyCategoryMaxAmount', + SET_POLICY_CATEGORY_APPROVER: 'SetPolicyCategoryApprover', + SET_POLICY_CATEGORY_TAX: 'SetPolicyCategoryTax', SET_POLICY_TAXES_CURRENCY_DEFAULT: 'SetPolicyCurrencyDefaultTax', SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT: 'SetPolicyForeignCurrencyDefaultTax', SET_POLICY_CUSTOM_TAX_NAME: 'SetPolicyCustomTaxName', @@ -350,6 +367,7 @@ const WRITE_COMMANDS = { CONFIGURE_EXPENSIFY_CARDS_FOR_POLICY: 'ConfigureExpensifyCardsForPolicy', CREATE_EXPENSIFY_CARD: 'CreateExpensifyCard', CREATE_ADMIN_ISSUED_VIRTUAL_CARD: 'CreateAdminIssuedVirtualCard', + ADD_DELEGATE: 'AddDelegate', TOGGLE_CARD_CONTINUOUS_RECONCILIATION: 'ToggleCardContinuousReconciliation', UPDATE_CARD_SETTLEMENT_FREQUENCY: 'UpdateCardSettlementFrequency', UPDATE_CARD_SETTLEMENT_ACCOUNT: 'UpdateCardSettlementAccount', @@ -485,7 +503,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CREATE_WORKSPACE_FROM_IOU_PAYMENT]: Parameters.CreateWorkspaceFromIOUPaymentParams; [WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED]: Parameters.SetWorkspaceCategoriesEnabledParams; [WRITE_COMMANDS.CREATE_WORKSPACE_CATEGORIES]: Parameters.CreateWorkspaceCategoriesParams; - [WRITE_COMMANDS.IMPORT_CATEGORIES_SREADSHEET]: Parameters.ImportCategoriesSpreadsheetParams; + [WRITE_COMMANDS.IMPORT_CATEGORIES_SPREADSHEET]: Parameters.ImportCategoriesSpreadsheetParams; + [WRITE_COMMANDS.EXPORT_CATEGORIES_CSV]: Parameters.ExportCategoriesSpreadsheetParams; [WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; [WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES]: Parameters.DeleteWorkspaceCategoriesParams; @@ -551,6 +570,15 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE]: Parameters.SetWorkspaceApprovalModeParams; [WRITE_COMMANDS.SET_WORKSPACE_PAYER]: Parameters.SetWorkspacePayerParams; [WRITE_COMMANDS.SET_WORKSPACE_REIMBURSEMENT]: Parameters.SetWorkspaceReimbursementParams; + [WRITE_COMMANDS.SET_POLICY_DEFAULT_REPORT_TITLE]: Parameters.SetPolicyDefaultReportTitleParams; + [WRITE_COMMANDS.SET_POLICY_PREVENT_MEMBER_CREATED_TITLE]: Parameters.SetPolicyPreventMemberCreatedTitleParams; + [WRITE_COMMANDS.SET_POLICY_PREVENT_SELF_APPROVAL]: Parameters.SetPolicyPreventSelfApprovalParams; + [WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_LIMIT]: Parameters.SetPolicyAutomaticApprovalLimitParams; + [WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_RATE]: Parameters.SetPolicyAutomaticApprovalRateParams; + [WRITE_COMMANDS.SET_POLICY_AUTO_REIMBURSEMENT_LIMIT]: Parameters.SetPolicyAutoReimbursementLimitParams; + [WRITE_COMMANDS.ENABLE_POLICY_AUTO_REIMBURSEMENT_LIMIT]: Parameters.EnablePolicyAutoReimbursementLimitParams; + [WRITE_COMMANDS.ENABLE_POLICY_AUTO_APPROVAL_OPTIONS]: Parameters.EnablePolicyAutoApprovalOptionsParams; + [WRITE_COMMANDS.ENABLE_POLICY_DEFAULT_REPORT_TITLE]: Parameters.EnablePolicyDefaultReportTitleParams; [WRITE_COMMANDS.SET_WORKSPACE_DEFAULT_SPEND_CATEGORY]: Parameters.SetWorkspaceDefaultSpendCategoryParams; [WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams; [WRITE_COMMANDS.TRACK_EXPENSE]: Parameters.TrackExpenseParams; @@ -565,6 +593,13 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_COMPANY_CARDS]: Parameters.EnablePolicyCompanyCardsParams; [WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; + [WRITE_COMMANDS.SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT]: Parameters.SetWorkspaceCategoryDescriptionHintParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_RECEIPTS_REQUIRED]: Parameters.SetPolicyCategoryReceiptsRequiredParams; + [WRITE_COMMANDS.REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED]: Parameters.RemovePolicyCategoryReceiptsRequiredParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_MAX_AMOUNT]: Parameters.SetPolicyCategoryMaxAmountParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_APPROVER]: Parameters.SetPolicyCategoryApproverParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX]: Parameters.SetPolicyCategoryTaxParams; [WRITE_COMMANDS.JOIN_POLICY_VIA_INVITE_LINK]: Parameters.JoinPolicyInviteLinkParams; [WRITE_COMMANDS.ACCEPT_JOIN_REQUEST]: Parameters.AcceptJoinRequestParams; [WRITE_COMMANDS.DECLINE_JOIN_REQUEST]: Parameters.DeclineJoinRequestParams; @@ -717,8 +752,9 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL]: Parameters.UpdateWorkspaceApprovalParams; [WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL]: Parameters.RemoveWorkspaceApprovalParams; [WRITE_COMMANDS.CONFIGURE_EXPENSIFY_CARDS_FOR_POLICY]: Parameters.ConfigureExpensifyCardsForPolicyParams; - [WRITE_COMMANDS.CREATE_EXPENSIFY_CARD]: Parameters.CreateExpensifyCardParams; + [WRITE_COMMANDS.CREATE_EXPENSIFY_CARD]: Omit<Parameters.CreateExpensifyCardParams, 'domainAccountID'>; [WRITE_COMMANDS.CREATE_ADMIN_ISSUED_VIRTUAL_CARD]: Omit<Parameters.CreateExpensifyCardParams, 'feedCountry'>; + [WRITE_COMMANDS.ADD_DELEGATE]: Parameters.AddDelegateParams; [WRITE_COMMANDS.TOGGLE_CARD_CONTINUOUS_RECONCILIATION]: Parameters.ToggleCardContinuousReconciliationParams; [WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_FREQUENCY]: Parameters.UpdateCardSettlementFrequencyParams; [WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_ACCOUNT]: Parameters.UpdateCardSettlementAccountParams; diff --git a/src/libs/AccountUtils.ts b/src/libs/AccountUtils.ts index b926e20ca59c..b5b58bfbf70c 100644 --- a/src/libs/AccountUtils.ts +++ b/src/libs/AccountUtils.ts @@ -8,4 +8,11 @@ const isValidateCodeFormSubmitting = (account: OnyxEntry<Account>) => /** Whether the accound ID is an odd number, useful for A/B testing. */ const isAccountIDOddNumber = (accountID: number) => accountID % 2 === 1; -export default {isValidateCodeFormSubmitting, isAccountIDOddNumber}; +function isDelegateOnlySubmitter(account: OnyxEntry<Account>): boolean { + const delegateEmail = account?.delegatedAccess?.delegate; + const delegateRole = account?.delegatedAccess?.delegates?.find((delegate) => delegate.email === delegateEmail)?.role; + + return delegateRole === CONST.DELEGATE_ROLE.SUBMITTER; +} + +export default {isValidateCodeFormSubmitting, isAccountIDOddNumber, isDelegateOnlySubmitter}; diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts new file mode 100644 index 000000000000..7f971f37d3fa --- /dev/null +++ b/src/libs/CategoryUtils.ts @@ -0,0 +1,62 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import CONST from '@src/CONST'; +import type {Policy, TaxRate, TaxRatesWithDefault} from '@src/types/onyx'; +import type {ApprovalRule, ExpenseRule} from '@src/types/onyx/Policy'; +import * as CurrencyUtils from './CurrencyUtils'; + +function formatDefaultTaxRateText(translate: LocaleContextProps['translate'], taxID: string, taxRate: TaxRate, policyTaxRates?: TaxRatesWithDefault) { + const taxRateText = `${taxRate.name} ${CONST.DOT_SEPARATOR} ${taxRate.value}`; + + if (!policyTaxRates) { + return taxRateText; + } + + const {defaultExternalID, foreignTaxDefault} = policyTaxRates; + let suffix; + + if (taxID === defaultExternalID && taxID === foreignTaxDefault) { + suffix = translate('common.default'); + } else if (taxID === defaultExternalID) { + suffix = translate('workspace.taxes.workspaceDefault'); + } else if (taxID === foreignTaxDefault) { + suffix = translate('workspace.taxes.foreignDefault'); + } + return `${taxRateText}${suffix ? ` ${CONST.DOT_SEPARATOR} ${suffix}` : ``}`; +} + +function formatRequireReceiptsOverText(translate: LocaleContextProps['translate'], policy: Policy, categoryMaxExpenseAmountNoReceipt?: number | null) { + const isAlwaysSelected = categoryMaxExpenseAmountNoReceipt === 0; + const isNeverSelected = categoryMaxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; + + if (isAlwaysSelected) { + return translate(`workspace.rules.categoryRules.requireReceiptsOverList.always`); + } + + if (isNeverSelected) { + return translate(`workspace.rules.categoryRules.requireReceiptsOverList.never`); + } + + const maxExpenseAmountToDisplay = policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmount; + + return translate( + `workspace.rules.categoryRules.requireReceiptsOverList.default`, + CurrencyUtils.convertToShortDisplayString(maxExpenseAmountToDisplay, policy?.outputCurrency ?? CONST.CURRENCY.USD), + ); +} + +function getCategoryApprover(approvalRules: ApprovalRule[], categoryName: string) { + return approvalRules?.find((rule) => rule.applyWhen.some((when) => when.value === categoryName))?.approver; +} + +function getCategoryDefaultTaxRate(expenseRules: ExpenseRule[], categoryName: string, defaultTaxRate?: string) { + const categoryDefaultTaxRate = expenseRules?.find((rule) => rule.applyWhen.some((when) => when.value === categoryName))?.tax?.field_id_TAX?.externalID; + + // If the default taxRate is not found in expenseRules, use the default value for policy + if (!categoryDefaultTaxRate) { + return defaultTaxRate; + } + + return categoryDefaultTaxRate; +} + +export {formatDefaultTaxRateText, formatRequireReceiptsOverText, getCategoryApprover, getCategoryDefaultTaxRate}; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index b5af91dfacfb..e9a2eaa8027d 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -19,6 +19,7 @@ type MileageRate = { currency?: string; unit: Unit; name?: string; + enabled?: boolean; }; let lastSelectedDistanceRates: OnyxEntry<LastSelectedDistanceRates> = {}; @@ -32,7 +33,7 @@ Onyx.connect({ const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter -function getMileageRates(policy: OnyxInputOrEntry<Policy>, includeDisabledRates = false): Record<string, MileageRate> { +function getMileageRates(policy: OnyxInputOrEntry<Policy>, includeDisabledRates = false, selectedRateID?: string): Record<string, MileageRate> { const mileageRates: Record<string, MileageRate> = {}; if (!policy?.customUnits) { @@ -45,7 +46,7 @@ function getMileageRates(policy: OnyxInputOrEntry<Policy>, includeDisabledRates } Object.entries(distanceUnit.rates).forEach(([rateID, rate]) => { - if (!includeDisabledRates && rate.enabled === false) { + if (!includeDisabledRates && rate.enabled === false && (!selectedRateID || rateID !== selectedRateID)) { return; } @@ -55,6 +56,7 @@ function getMileageRates(policy: OnyxInputOrEntry<Policy>, includeDisabledRates unit: distanceUnit.attributes.unit, name: rate.name, customUnitRateID: rate.customUnitRateID, + enabled: rate.enabled, }; }); diff --git a/src/libs/EmojiTrie.ts b/src/libs/EmojiTrie.ts index 88a1a7a275d4..e33ba77eab22 100644 --- a/src/libs/EmojiTrie.ts +++ b/src/libs/EmojiTrie.ts @@ -14,8 +14,6 @@ type EmojiMetaData = { name?: string; }; -Timing.start(CONST.TIMING.TRIE_INITIALIZATION); - const supportedLanguages = [CONST.LOCALES.DEFAULT, CONST.LOCALES.ES] as const; type SupportedLanguage = TupleToUnion<typeof supportedLanguages>; @@ -123,6 +121,7 @@ const emojiTrie: EmojiTrie = supportedLanguages.reduce((acc, lang) => { }, {} as EmojiTrie); const buildEmojisTrie = (locale: Locale) => { + Timing.start(CONST.TIMING.TRIE_INITIALIZATION); // Normalize the locale to lowercase and take the first part before any dash const normalizedLocale = locale.toLowerCase().split('-')[0]; const localeToUse = supportedLanguages.includes(normalizedLocale as SupportedLanguage) ? (normalizedLocale as SupportedLanguage) : undefined; @@ -131,10 +130,9 @@ const buildEmojisTrie = (locale: Locale) => { return; // Return early if the locale is not supported or the trie is already built } emojiTrie[localeToUse] = createTrie(localeToUse); + Timing.end(CONST.TIMING.TRIE_INITIALIZATION); }; -Timing.end(CONST.TIMING.TRIE_INITIALIZATION); - export default emojiTrie; export {buildEmojisTrie}; diff --git a/src/libs/Firebase/index.native.ts b/src/libs/Firebase/index.native.ts index d2746d8b25e7..0af52eefb58c 100644 --- a/src/libs/Firebase/index.native.ts +++ b/src/libs/Firebase/index.native.ts @@ -41,7 +41,7 @@ const stopTrace: StopTrace = (customEventName) => { return; } - const trace = traceMap[customEventName].trace; + const trace = traceMap[customEventName]?.trace; if (!trace) { return; } diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index a8ef8a90dffe..550a75c3d361 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -37,9 +37,6 @@ const abortControllerMap = new Map<AbortCommand, AbortController>(); abortControllerMap.set(ABORT_COMMANDS.All, new AbortController()); abortControllerMap.set(ABORT_COMMANDS.SearchForReports, new AbortController()); -// Some existing old commands (6+ years) exempted from the auth writes count check -const exemptedCommandsWithAuthWrites: string[] = ['SetWorkspaceAutoReportingFrequency']; - /** * The API commands that require the skew calculation */ @@ -133,7 +130,7 @@ function processHTTPRequest(url: string, method: RequestType = 'get', body: Form }); } - if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR && !exemptedCommandsWithAuthWrites.includes(response.data?.phpCommandName ?? '')) { + if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR) { if (response.data) { const {phpCommandName, authWriteCommands} = response.data; // eslint-disable-next-line max-len diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 67ba9f62421d..206bb8509af6 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -49,6 +49,15 @@ function validateAmount(amount: string, decimals: number, amountMaxLength: numbe return amount === '' || decimalNumberRegex.test(amount); } +/** + * Check if percentage is between 0 and 100 + */ +function validatePercentage(amount: string): boolean { + const regexString = '^(100|[0-9]{1,2})$'; + const percentageRegex = new RegExp(regexString, 'i'); + return amount === '' || percentageRegex.test(amount); +} + /** * Replaces each character by calling `convertFn`. If `convertFn` throws an error, then * the original character will be preserved. @@ -80,4 +89,15 @@ function isScanRequest(selectedTab: SelectedTabRequest): boolean { return selectedTab === CONST.TAB_REQUEST.SCAN; } -export {addLeadingZero, isDistanceRequest, isScanRequest, replaceAllDigits, stripCommaFromAmount, stripDecimalsFromAmount, stripSpacesFromAmount, replaceCommasWithPeriod, validateAmount}; +export { + addLeadingZero, + isDistanceRequest, + isScanRequest, + replaceAllDigits, + stripCommaFromAmount, + stripDecimalsFromAmount, + stripSpacesFromAmount, + replaceCommasWithPeriod, + validateAmount, + validatePercentage, +}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 5eb3484a4a69..f2461f400678 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -29,7 +29,6 @@ import * as SessionUtils from '@libs/SessionUtils'; import ConnectionCompletePage from '@pages/ConnectionCompletePage'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import DesktopSignInRedirectPage from '@pages/signin/DesktopSignInRedirectPage'; -import SearchInputManager from '@pages/workspace/SearchInputManager'; import * as App from '@userActions/App'; import * as Download from '@userActions/Download'; import * as Modal from '@userActions/Modal'; @@ -198,8 +197,6 @@ const modalScreenListeners = { Modal.setModalVisibility(false); }, beforeRemove: () => { - // Clear search input (WorkspaceInvitePage) when modal is closed - SearchInputManager.searchInput = ''; Modal.setModalVisibility(false); Modal.willAlertModalBecomeVisible(false); }, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index c2a30f20ed56..7f3c12ad31f1 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -18,7 +18,6 @@ import type { ReportDescriptionNavigatorParamList, ReportDetailsNavigatorParamList, ReportSettingsNavigatorParamList, - RoomInviteNavigatorParamList, RoomMembersNavigatorParamList, SearchAdvancedFiltersParamList, SearchReportParamList, @@ -149,11 +148,9 @@ const ReportParticipantsModalStackNavigator = createModalStackNavigator<Particip }); const RoomMembersModalStackNavigator = createModalStackNavigator<RoomMembersNavigatorParamList>({ - [SCREENS.ROOM_MEMBERS_ROOT]: () => require<ReactComponentModule>('../../../../pages/RoomMembersPage').default, -}); - -const RoomInviteModalStackNavigator = createModalStackNavigator<RoomInviteNavigatorParamList>({ - [SCREENS.ROOM_INVITE_ROOT]: () => require<ReactComponentModule>('../../../../pages/RoomInvitePage').default, + [SCREENS.ROOM_MEMBERS.ROOT]: () => require<ReactComponentModule>('../../../../pages/RoomMembersPage').default, + [SCREENS.ROOM_MEMBERS.INVITE]: () => require<ReactComponentModule>('../../../../pages/RoomInvitePage').default, + [SCREENS.ROOM_MEMBERS.DETAILS]: () => require<ReactComponentModule>('../../../../pages/RoomMemberDetailsPage').default, }); const NewChatModalStackNavigator = createModalStackNavigator<NewChatNavigatorParamList>({ @@ -250,6 +247,11 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP [SCREENS.WORKSPACE.CATEGORY_EDIT]: () => require<ReactComponentModule>('../../../../pages/workspace/categories/EditCategoryPage').default, [SCREENS.WORKSPACE.CATEGORY_PAYROLL_CODE]: () => require<ReactComponentModule>('../../../../pages/workspace/categories/CategoryPayrollCodePage').default, [SCREENS.WORKSPACE.CATEGORY_GL_CODE]: () => require<ReactComponentModule>('../../../../pages/workspace/categories/CategoryGLCodePage').default, + [SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE]: () => require<ReactComponentModule>('../../../../pages/workspace/categories/CategoryDefaultTaxRatePage').default, + [SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER]: () => require<ReactComponentModule>('../../../../pages/workspace/categories/CategoryFlagAmountsOverPage').default, + [SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT]: () => require<ReactComponentModule>('../../../../pages/workspace/categories/CategoryDescriptionHintPage').default, + [SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER]: () => require<ReactComponentModule>('../../../../pages/workspace/categories/CategoryRequireReceiptsOverPage').default, + [SCREENS.WORKSPACE.CATEGORY_APPROVER]: () => require<ReactComponentModule>('../../../../pages/workspace/categories/CategoryApproverPage').default, [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: () => require<ReactComponentModule>('../../../../pages/workspace/distanceRates/CreateDistanceRatePage').default, [SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: () => require<ReactComponentModule>('../../../../pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage').default, [SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: () => require<ReactComponentModule>('../../../../pages/workspace/distanceRates/PolicyDistanceRateDetailsPage').default, @@ -426,6 +428,7 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP [SCREENS.WORKSPACE.INVOICES_COMPANY_WEBSITE]: () => require<ReactComponentModule>('../../../../pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite').default, [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD]: () => require<ReactComponentModule>('../../../../pages/workspace/companyCards/assignCard/AssignCardFeedPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED]: () => require<ReactComponentModule>('../../../../pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage').default, + [SCREENS.WORKSPACE.COMPANY_CARDS_ADD_NEW]: () => require<ReactComponentModule>('../../../../pages/workspace/companyCards/addNew/AddNewCardPage').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW]: () => require<ReactComponentModule>('../../../../pages/workspace/expensifyCard/issueNew/IssueNewCardPage').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS]: () => require<ReactComponentModule>('../../../../pages/workspace/expensifyCard/WorkspaceCardSettingsPage').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS_ACCOUNT]: () => require<ReactComponentModule>('../../../../pages/workspace/expensifyCard/WorkspaceSettlementAccountPage').default, @@ -460,6 +463,14 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP require<ReactComponentModule>('../../../../pages/workspace/accounting/intacct/import/SageIntacctAddUserDimensionPage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EDIT_USER_DIMENSION]: () => require<ReactComponentModule>('../../../../pages/workspace/accounting/intacct/import/SageIntacctEditUserDimensionsPage').default, + [SCREENS.SETTINGS.DELEGATE.ADD_DELEGATE]: () => require<ReactComponentModule>('../../../../pages/settings/Security/AddDelegate/AddDelegatePage').default, + [SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE]: () => require<ReactComponentModule>('../../../../pages/settings/Security/AddDelegate/SelectDelegateRolePage').default, + [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: () => require<ReactComponentModule>('../../../../pages/settings/Security/AddDelegate/ConfirmDelegatePage').default, + [SCREENS.SETTINGS.DELEGATE.DELEGATE_MAGIC_CODE]: () => require<ReactComponentModule>('../../../../pages/settings/Security/AddDelegate/DelegateMagicCodePage').default, + [SCREENS.WORKSPACE.RULES_CUSTOM_NAME]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/RulesCustomNamePage').default, + [SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/RulesAutoApproveReportsUnderPage').default, + [SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/RulesRandomReportAuditPage').default, + [SCREENS.WORKSPACE.RULES_AUTO_PAY_REPORTS_UNDER]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/RulesAutoPayReportsUnderPage').default, [SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/RulesReceiptRequiredAmountPage').default, [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AMOUNT]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/RulesMaxExpenseAmountPage').default, [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/RulesMaxExpenseAgePage').default, @@ -540,7 +551,6 @@ const SearchAdvancedFiltersModalStackNavigator = createModalStackNavigator<Searc [SCREENS.SEARCH.ADVANCED_FILTERS_FROM_RHP]: () => require<ReactComponentModule>('@pages/Search/SearchAdvancedFiltersPage/SearchFiltersFromPage').default, [SCREENS.SEARCH.ADVANCED_FILTERS_TO_RHP]: () => require<ReactComponentModule>('@pages/Search/SearchAdvancedFiltersPage/SearchFiltersToPage').default, [SCREENS.SEARCH.ADVANCED_FILTERS_IN_RHP]: () => require<ReactComponentModule>('@pages/Search/SearchAdvancedFiltersPage/SearchFiltersInPage').default, - [SCREENS.SEARCH.ADVANCED_FILTERS_HAS_RHP]: () => require<ReactComponentModule>('@pages/Search/SearchAdvancedFiltersPage/SearchFiltersHasPage').default, }); const RestrictedActionModalStackNavigator = createModalStackNavigator<SearchReportParamList>({ @@ -566,7 +576,6 @@ export { ReportDetailsModalStackNavigator, ReportParticipantsModalStackNavigator, ReportSettingsModalStackNavigator, - RoomInviteModalStackNavigator, RoomMembersModalStackNavigator, SettingsModalStackNavigator, SignInModalStackNavigator, diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index fb012139f9d8..444a0d927cc8 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -107,10 +107,6 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { name={SCREENS.RIGHT_MODAL.ROOM_MEMBERS} component={ModalStackNavigators.RoomMembersModalStackNavigator} /> - <Stack.Screen - name={SCREENS.RIGHT_MODAL.ROOM_INVITE} - component={ModalStackNavigators.RoomInviteModalStackNavigator} - /> <Stack.Screen name={SCREENS.RIGHT_MODAL.MONEY_REQUEST} component={ModalStackNavigators.MoneyRequestModalStackNavigator} diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx index f432d863704e..d06be872c70a 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx @@ -13,9 +13,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@libs/actions/Session'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import linkingConfig from '@libs/Navigation/linkingConfig'; -import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import Navigation from '@libs/Navigation/Navigation'; import type {RootStackParamList, State} from '@libs/Navigation/types'; import {isCentralPaneName} from '@libs/NavigationUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -26,6 +24,7 @@ import BottomTabAvatar from '@pages/home/sidebar/BottomTabAvatar'; import BottomTabBarFloatingActionButton from '@pages/home/sidebar/BottomTabBarFloatingActionButton'; import variables from '@styles/variables'; import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -95,10 +94,7 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { } Welcome.isOnboardingFlowCompleted({ - onNotCompleted: () => { - const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT.route, linkingConfig.config); - navigationRef.resetRoot(adaptedState); - }, + onNotCompleted: () => OnboardingFlow.startOnboardingFlow(), }); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps diff --git a/src/libs/Navigation/AppNavigator/index.native.tsx b/src/libs/Navigation/AppNavigator/index.native.tsx index 984ed8a2e46f..159626a45df4 100644 --- a/src/libs/Navigation/AppNavigator/index.native.tsx +++ b/src/libs/Navigation/AppNavigator/index.native.tsx @@ -10,7 +10,7 @@ type AppNavigatorProps = { }; function AppNavigator({authenticated}: AppNavigatorProps) { - const {initialURL} = useContext(InitialURLContext); + const {initialURL, setInitialURL} = useContext(InitialURLContext); useEffect(() => { if (!NativeModules.HybridAppModule || !initialURL) { @@ -18,9 +18,10 @@ function AppNavigator({authenticated}: AppNavigatorProps) { } Navigation.isNavigationReady().then(() => { - Navigation.navigate(initialURL); + Navigation.navigate(Navigation.parseHybridAppUrl(initialURL)); + setInitialURL(undefined); }); - }, [initialURL]); + }, [initialURL, setInitialURL]); if (authenticated) { const AuthScreens = require<ReactComponentModule>('./AuthScreens').default; diff --git a/src/libs/Navigation/AppNavigator/index.tsx b/src/libs/Navigation/AppNavigator/index.tsx index 1901a51563e9..05961528ca11 100644 --- a/src/libs/Navigation/AppNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/index.tsx @@ -1,8 +1,4 @@ -import React, {lazy, memo, Suspense, useContext, useEffect} from 'react'; -import {NativeModules} from 'react-native'; -import {InitialURLContext} from '@components/InitialURLContextProvider'; -import Navigation from '@libs/Navigation/Navigation'; -import ROUTES from '@src/ROUTES'; +import React, {lazy, memo, Suspense} from 'react'; import lazyRetry from '@src/utils/lazyRetry'; const AuthScreens = lazy(() => lazyRetry(() => import('./AuthScreens'))); @@ -14,18 +10,6 @@ type AppNavigatorProps = { }; function AppNavigator({authenticated}: AppNavigatorProps) { - const {initialURL} = useContext(InitialURLContext); - - useEffect(() => { - if (!NativeModules.HybridAppModule || !initialURL || !initialURL.includes(ROUTES.TRANSITION_BETWEEN_APPS)) { - return; - } - - Navigation.isNavigationReady().then(() => { - Navigation.navigate(initialURL); - }); - }, [initialURL]); - if (authenticated) { // These are the protected screens and only accessible when an authToken is present return ( diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 4bd86503fd2b..4c61b953f572 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -102,9 +102,13 @@ function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number */ function parseHybridAppUrl(url: HybridAppRoute | Route): Route { switch (url) { + case HYBRID_APP_ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL: + return ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID()); + case HYBRID_APP_ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE: + return ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID()); case HYBRID_APP_ROUTES.MONEY_REQUEST_CREATE: - case HYBRID_APP_ROUTES.MONEY_REQUEST_SUBMIT_CREATE: - return ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID()); + case HYBRID_APP_ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN: + return ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID()); default: return url; } diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index ba489d67aeb5..a1aa53bc0b7e 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -15,6 +15,8 @@ import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFl import Log from '@libs/Log'; import {getPathFromURL} from '@libs/Url'; import {updateLastVisitedPath} from '@userActions/App'; +import {updateOnboardingLastVisitedPath} from '@userActions/Welcome'; +import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -59,6 +61,9 @@ function parseAndLogRoute(state: NavigationState) { if (focusedRoute && !CONST.EXCLUDE_FROM_LAST_VISITED_PATH.includes(focusedRoute?.name)) { updateLastVisitedPath(currentPath); + if (currentPath.startsWith(`/${ROUTES.ONBOARDING_ROOT.route}`)) { + updateOnboardingLastVisitedPath(currentPath); + } } // Don't log the route transitions from OldDot because they contain authTokens @@ -99,7 +104,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh // If the user haven't completed the flow, we want to always redirect them to the onboarding flow. // We also make sure that the user is authenticated. if (!NativeModules.HybridAppModule && !hasCompletedGuidedSetupFlow && authenticated && !shouldShowRequire2FAModal) { - const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT.route, linkingConfig.config); + const {adaptedState} = getAdaptedStateFromPath(getOnboardingInitialPath(), linkingConfig.config); return adaptedState; } diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts index ff3c60693cbc..e679631dfbbc 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -38,7 +38,14 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial<Record<CentralPaneName, string[]>> = SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD, SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS, ], - [SCREENS.SETTINGS.SECURITY]: [SCREENS.SETTINGS.TWO_FACTOR_AUTH, SCREENS.SETTINGS.CLOSE], + [SCREENS.SETTINGS.SECURITY]: [ + SCREENS.SETTINGS.TWO_FACTOR_AUTH, + SCREENS.SETTINGS.CLOSE, + SCREENS.SETTINGS.DELEGATE.ADD_DELEGATE, + SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE, + SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM, + SCREENS.SETTINGS.DELEGATE.DELEGATE_MAGIC_CODE, + ], [SCREENS.SETTINGS.ABOUT]: [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS], [SCREENS.SETTINGS.SAVE_THE_WORLD]: [SCREENS.I_KNOW_A_TEACHER, SCREENS.INTRO_SCHOOL_PRINCIPAL, SCREENS.I_AM_A_TEACHER], [SCREENS.SETTINGS.TROUBLESHOOT]: [SCREENS.SETTINGS.CONSOLE], @@ -61,7 +68,6 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial<Record<CentralPaneName, string[]>> = SCREENS.SEARCH.ADVANCED_FILTERS_TO_RHP, SCREENS.SEARCH.ADVANCED_FILTERS_IN_RHP, SCREENS.SEARCH.ADVANCED_FILTERS_CARD_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_HAS_RHP, ], [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: [ SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD, diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index 22db5deaebfb..72d513844d81 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -143,6 +143,11 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial<Record<FullScreenName, string[]>> = { SCREENS.WORKSPACE.CATEGORY_EDIT, SCREENS.WORKSPACE.CATEGORY_GL_CODE, SCREENS.WORKSPACE.CATEGORY_PAYROLL_CODE, + SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE, + SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER, + SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT, + SCREENS.WORKSPACE.CATEGORY_APPROVER, + SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER, ], [SCREENS.WORKSPACE.DISTANCE_RATES]: [ SCREENS.WORKSPACE.CREATE_DISTANCE_RATE, @@ -164,6 +169,14 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial<Record<FullScreenName, string[]>> = { [SCREENS.WORKSPACE.INVOICES]: [SCREENS.WORKSPACE.INVOICES_COMPANY_NAME, SCREENS.WORKSPACE.INVOICES_COMPANY_WEBSITE], [SCREENS.WORKSPACE.COMPANY_CARDS]: [ SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED, + SCREENS.WORKSPACE.COMPANY_CARDS_ADD_NEW, + SCREENS.WORKSPACE.COMPANY_CARDS_TYPE, + SCREENS.WORKSPACE.COMPANY_CARDS_INSTRUCTIONS, + SCREENS.WORKSPACE.COMPANY_CARDS_NAME, + SCREENS.WORKSPACE.COMPANY_CARDS_DETAILS, + SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED, + SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS, + SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS_FEED_NAME, SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS, SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS_FEED_NAME, SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD, @@ -180,6 +193,10 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial<Record<FullScreenName, string[]>> = { SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT_TYPE, ], [SCREENS.WORKSPACE.RULES]: [ + SCREENS.WORKSPACE.RULES_CUSTOM_NAME, + SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER, + SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT, + SCREENS.WORKSPACE.RULES_AUTO_PAY_REPORTS_UNDER, SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT, SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AMOUNT, SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index cf45126bfc04..16cb5a1e1bf2 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -107,8 +107,9 @@ const config: LinkingOptions<RootStackParamList>['config'] = { }, }, [NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: { + // Don't set the initialRouteName, because when the user continues from the last visited onboarding page, + // the onboarding purpose page will be briefly visible. path: ROUTES.ONBOARDING_ROOT.route, - initialRouteName: SCREENS.ONBOARDING.PURPOSE, screens: { [SCREENS.ONBOARDING.PURPOSE]: { path: ROUTES.ONBOARDING_PURPOSE.route, @@ -279,6 +280,28 @@ const config: LinkingOptions<RootStackParamList>['config'] = { path: ROUTES.SETTINGS_2FA.route, exact: true, }, + [SCREENS.SETTINGS.DELEGATE.ADD_DELEGATE]: { + path: ROUTES.SETTINGS_ADD_DELEGATE, + exact: true, + }, + [SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE]: { + path: ROUTES.SETTINGS_DELEGATE_ROLE.route, + parse: { + login: (login: string) => decodeURIComponent(login), + }, + }, + [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: { + path: ROUTES.SETTINGS_DELEGATE_CONFIRM.route, + parse: { + login: (login: string) => decodeURIComponent(login), + }, + }, + [SCREENS.SETTINGS.DELEGATE.DELEGATE_MAGIC_CODE]: { + path: ROUTES.SETTINGS_DELEGATE_MAGIC_CODE.route, + parse: { + login: (login: string) => decodeURIComponent(login), + }, + }, [SCREENS.SETTINGS.PROFILE.STATUS]: { path: ROUTES.SETTINGS_STATUS, exact: true, @@ -517,6 +540,9 @@ const config: LinkingOptions<RootStackParamList>['config'] = { [SCREENS.WORKSPACE.EXPENSIFY_CARD_DETAILS]: { path: ROUTES.WORKSPACE_EXPENSIFY_CARD_DETAILS.route, }, + [SCREENS.WORKSPACE.COMPANY_CARDS_ADD_NEW]: { + path: ROUTES.WORKSPACE_COMPANY_CARDS_ADD_NEW.route, + }, [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD]: { path: ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.route, }, @@ -607,6 +633,36 @@ const config: LinkingOptions<RootStackParamList>['config'] = { categoryName: (categoryName: string) => decodeURIComponent(categoryName), }, }, + [SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE]: { + path: ROUTES.WORSKPACE_CATEGORY_DEFAULT_TAX_RATE.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER]: { + path: ROUTES.WORSKPACE_CATEGORY_FLAG_AMOUNTS_OVER.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT]: { + path: ROUTES.WORSKPACE_CATEGORY_DESCRIPTION_HINT.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_APPROVER]: { + path: ROUTES.WORSKPACE_CATEGORY_APPROVER.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, + [SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER]: { + path: ROUTES.WORSKPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.route, + parse: { + categoryName: (categoryName: string) => decodeURIComponent(categoryName), + }, + }, [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: { path: ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.route, }, @@ -762,6 +818,18 @@ const config: LinkingOptions<RootStackParamList>['config'] = { taxID: (taxID: string) => decodeURIComponent(taxID), }, }, + [SCREENS.WORKSPACE.RULES_CUSTOM_NAME]: { + path: ROUTES.RULES_CUSTOM_NAME.route, + }, + [SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER]: { + path: ROUTES.RULES_AUTO_APPROVE_REPORTS_UNDER.route, + }, + [SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT]: { + path: ROUTES.RULES_RANDOM_REPORT_AUDIT.route, + }, + [SCREENS.WORKSPACE.RULES_AUTO_PAY_REPORTS_UNDER]: { + path: ROUTES.RULES_AUTO_PAY_REPORTS_UNDER.route, + }, [SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT]: { path: ROUTES.RULES_RECEIPT_REQUIRED_AMOUNT.route, }, @@ -891,14 +959,11 @@ const config: LinkingOptions<RootStackParamList>['config'] = { [SCREENS.REPORT_PARTICIPANTS.ROLE]: ROUTES.REPORT_PARTICIPANTS_ROLE_SELECTION.route, }, }, - [SCREENS.RIGHT_MODAL.ROOM_INVITE]: { - screens: { - [SCREENS.ROOM_INVITE_ROOT]: ROUTES.ROOM_INVITE.route, - }, - }, [SCREENS.RIGHT_MODAL.ROOM_MEMBERS]: { screens: { - [SCREENS.ROOM_MEMBERS_ROOT]: ROUTES.ROOM_MEMBERS.route, + [SCREENS.ROOM_MEMBERS.ROOT]: ROUTES.ROOM_MEMBERS.route, + [SCREENS.ROOM_MEMBERS.INVITE]: ROUTES.ROOM_INVITE.route, + [SCREENS.ROOM_MEMBERS.DETAILS]: ROUTES.ROOM_MEMBER_DETAILS.route, }, }, [SCREENS.RIGHT_MODAL.MONEY_REQUEST]: { @@ -1069,7 +1134,6 @@ const config: LinkingOptions<RootStackParamList>['config'] = { [SCREENS.SEARCH.ADVANCED_FILTERS_FROM_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_FROM, [SCREENS.SEARCH.ADVANCED_FILTERS_TO_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_TO, [SCREENS.SEARCH.ADVANCED_FILTERS_IN_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_IN, - [SCREENS.SEARCH.ADVANCED_FILTERS_HAS_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_HAS, }, }, [SCREENS.RIGHT_MODAL.RESTRICTED_ACTION]: { diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 10e68ad4a6a8..2c96e5796309 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -47,7 +47,7 @@ type GetAdaptedStateReturnType = { metainfo: Metainfo; }; -type GetAdaptedStateFromPath = (...args: Parameters<typeof getStateFromPath>) => GetAdaptedStateReturnType; +type GetAdaptedStateFromPath = (...args: [...Parameters<typeof getStateFromPath>, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType; // The function getPathFromState that we are using in some places isn't working correctly without defined index. const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState<NavigationState> => ({routes, index: routes.length - 1}); @@ -365,7 +365,7 @@ function getAdaptedState(state: PartialState<NavigationState<RootStackParamList> }; } -const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options) => { +const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldReplacePathInNestedState = true) => { const normalizedPath = !path.startsWith('/') ? `/${path}` : path; const pathWithoutPolicyID = getPathWithoutPolicyID(normalizedPath); const isAnonymous = isAnonymousUser(); @@ -374,7 +374,9 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options) => { const policyID = isAnonymous ? undefined : extractPolicyIDFromPath(path); const state = getStateFromPath(pathWithoutPolicyID, options) as PartialState<NavigationState<RootStackParamList>>; - replacePathInNestedState(state, path); + if (shouldReplacePathInNestedState) { + replacePathInNestedState(state, path); + } if (state === undefined) { throw new Error('Unable to parse path'); } diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index c8c2c0f0e41d..ce7ae13d9808 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -213,6 +213,26 @@ type SettingsNavigatorParamList = { policyID: string; categoryName: string; }; + [SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_APPROVER]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER]: { + policyID: string; + categoryName: string; + }; [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: { policyID: string; categoryName: string; @@ -632,6 +652,19 @@ type SettingsNavigatorParamList = { backTo?: Routes; forwardTo?: string; }; + [SCREENS.SETTINGS.DELEGATE.ADD_DELEGATE]: undefined; + [SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE]: { + login: string; + role?: string; + }; + [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: { + login: string; + role: string; + }; + [SCREENS.SETTINGS.DELEGATE.DELEGATE_MAGIC_CODE]: { + login: string; + role: string; + }; [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: { /** cardID of selected card */ cardID: string; @@ -712,6 +745,18 @@ type SettingsNavigatorParamList = { policyID: string; cardID: string; }; + [SCREENS.WORKSPACE.RULES_CUSTOM_NAME]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_AUTO_PAY_REPORTS_UNDER]: { + policyID: string; + }; [SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT]: { policyID: string; }; @@ -788,14 +833,15 @@ type ParticipantsNavigatorParamList = { }; type RoomMembersNavigatorParamList = { - [SCREENS.ROOM_MEMBERS_ROOT]: undefined; -}; - -type RoomInviteNavigatorParamList = { - [SCREENS.ROOM_INVITE_ROOT]: { + [SCREENS.ROOM_MEMBERS.ROOT]: {reportID: string}; + [SCREENS.ROOM_MEMBERS.INVITE]: { reportID: string; role?: 'accountant'; }; + [SCREENS.ROOM_MEMBERS.DETAILS]: { + reportID: string; + accountID: string; + }; }; type MoneyRequestNavigatorParamList = { @@ -1100,7 +1146,6 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.REPORT_DESCRIPTION]: NavigatorScreenParams<ReportDescriptionNavigatorParamList>; [SCREENS.RIGHT_MODAL.PARTICIPANTS]: NavigatorScreenParams<ParticipantsNavigatorParamList>; [SCREENS.RIGHT_MODAL.ROOM_MEMBERS]: NavigatorScreenParams<RoomMembersNavigatorParamList>; - [SCREENS.RIGHT_MODAL.ROOM_INVITE]: NavigatorScreenParams<RoomInviteNavigatorParamList>; [SCREENS.RIGHT_MODAL.MONEY_REQUEST]: NavigatorScreenParams<MoneyRequestNavigatorParamList>; [SCREENS.RIGHT_MODAL.NEW_TASK]: NavigatorScreenParams<NewTaskNavigatorParamList>; [SCREENS.RIGHT_MODAL.TEACHERS_UNITE]: NavigatorScreenParams<TeachersUniteNavigatorParamList>; @@ -1143,6 +1188,9 @@ type FullScreenNavigatorParamList = { [SCREENS.WORKSPACE.COMPANY_CARDS]: { policyID: string; }; + [SCREENS.WORKSPACE.COMPANY_CARDS_ADD_NEW]: { + policyID: string; + }; [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD]: { policyID: string; feed: string; @@ -1164,7 +1212,7 @@ type FullScreenNavigatorParamList = { }; [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_APPROVER]: { policyID: string; - approverIndex?: number; + approverIndex: number; backTo?: Routes; }; [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: { @@ -1332,6 +1380,7 @@ type AuthScreensParamList = CentralPaneScreensParamList & type SearchReportParamList = { [SCREENS.SEARCH.REPORT_RHP]: { reportID: string; + reportActionID?: string; }; }; @@ -1397,7 +1446,6 @@ export type { ReportDetailsNavigatorParamList, ReportSettingsNavigatorParamList, RightModalNavigatorParamList, - RoomInviteNavigatorParamList, RoomMembersNavigatorParamList, RootStackParamList, SettingsNavigatorParamList, diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index acc9d4bdefc5..f191c1d06532 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -7,6 +7,7 @@ import lodashSet from 'lodash/set'; import lodashSortBy from 'lodash/sortBy'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {SetNonNullable} from 'type-fest'; import {FallbackAvatar} from '@components/Icon/Expensicons'; import type {SelectedTagOption} from '@components/TagPicker'; import type {IOUAction} from '@src/CONST'; @@ -177,6 +178,7 @@ type GetOptionsConfig = { includeInvoiceRooms?: boolean; includeDomainEmail?: boolean; action?: IOUAction; + shouldBoldTitleByDefault?: boolean; }; type GetUserToInviteConfig = { @@ -305,7 +307,7 @@ Onyx.connect({ const transactionThreadReportID = ReportActionUtils.getOneTransactionThreadReportID(reportID, actions[reportActions[0]]); if (transactionThreadReportID) { const transactionThreadReportActionsArray = Object.values(actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); - sortedReportActions = ReportActionUtils.getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID); + sortedReportActions = ReportActionUtils.getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID, false); } lastReportActions[reportID] = sortedReportActions[0]; @@ -379,8 +381,8 @@ function getAvatarsForAccountIDs(accountIDs: number[], personalDetails: OnyxEntr * Returns the personal details for an array of accountIDs * @returns keys of the object are emails, values are PersonalDetails objects. */ -function getPersonalDetailsForAccountIDs(accountIDs: number[] | undefined, personalDetails: OnyxInputOrEntry<PersonalDetailsList>): PersonalDetailsList { - const personalDetailsForAccountIDs: PersonalDetailsList = {}; +function getPersonalDetailsForAccountIDs(accountIDs: number[] | undefined, personalDetails: OnyxInputOrEntry<PersonalDetailsList>): SetNonNullable<PersonalDetailsList> { + const personalDetailsForAccountIDs: SetNonNullable<PersonalDetailsList> = {}; if (!personalDetails) { return personalDetailsForAccountIDs; } @@ -469,13 +471,14 @@ function uniqFast(items: string[]): string[] { function getAllReportErrors(report: OnyxEntry<Report>, reportActions: OnyxEntry<ReportActions>): OnyxCommon.Errors { const reportErrors = report?.errors ?? {}; const reportErrorFields = report?.errorFields ?? {}; - const reportActionErrors: OnyxCommon.ErrorFields = Object.values(reportActions ?? {}).reduce((prevReportActionErrors, action) => { - if (!action || isEmptyObject(action.errors)) { - return prevReportActionErrors; - } + const reportActionsArray = Object.values(reportActions ?? {}); + const reportActionErrors: OnyxCommon.ErrorFields = {}; - return Object.assign(prevReportActionErrors, action.errors); - }, {}); + for (const action of reportActionsArray) { + if (action && !isEmptyObject(action.errors)) { + Object.assign(reportActionErrors, action.errors); + } + } const parentReportAction: OnyxEntry<ReportAction> = !report?.parentReportID || !report?.parentReportActionID ? undefined : allReportActions?.[report.parentReportID ?? '-1']?.[report.parentReportActionID ?? '-1']; @@ -489,24 +492,23 @@ function getAllReportErrors(report: OnyxEntry<Report>, reportActions: OnyxEntry< if (ReportUtils.shouldShowRBRForMissingSmartscanFields(report?.reportID ?? '-1') && !ReportUtils.isSettled(report?.reportID)) { reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage'); } - } else if (ReportUtils.hasSmartscanError(Object.values(reportActions ?? {}))) { + } else if (ReportUtils.hasSmartscanError(reportActionsArray)) { reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage'); } // All error objects related to the report. Each object in the sources contains error messages keyed by microtime - const errorSources = { - reportErrors, - ...reportErrorFields, - ...reportActionErrors, - }; - // Combine all error messages keyed by microtime into one object - const allReportErrors = Object.values(errorSources)?.reduce((prevReportErrors, errors) => { - if (isEmptyObject(errors)) { - return prevReportErrors; - } + // Use Object.assign to merge all error objects into one since it is faster and uses less memory than spread operator + // eslint-disable-next-line prefer-object-spread + const errorSources = Object.assign({}, reportErrors, reportErrorFields, reportActionErrors); - return Object.assign(prevReportErrors, errors); - }, {}); + // Combine all error messages keyed by microtime into one object + const errorSourcesArray = Object.values(errorSources ?? {}); + const allReportErrors = {}; + for (const errors of errorSourcesArray) { + if (!isEmptyObject(errors)) { + Object.assign(allReportErrors, errors); + } + } return allReportErrors; } @@ -753,7 +755,7 @@ function createOption( result.isWaitingOnBankAccount = report.isWaitingOnBankAccount; result.policyID = report.policyID; result.isSelfDM = ReportUtils.isSelfDM(report); - result.notificationPreference = report.notificationPreference; + result.notificationPreference = ReportUtils.getReportNotificationPreference(report); const visibleParticipantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report, true); @@ -847,7 +849,7 @@ function getPolicyExpenseReportOption(participant: Participant | ReportUtils.Opt const expenseReport = ReportUtils.isPolicyExpenseChat(participant) ? ReportUtils.getReportOrDraftReport(participant.reportID) : null; const visibleParticipantAccountIDs = Object.entries(expenseReport?.participants ?? {}) - .filter(([, reportParticipant]) => reportParticipant && !reportParticipant.hidden) + .filter(([, reportParticipant]) => reportParticipant && reportParticipant.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) .map(([accountID]) => Number(accountID)); const option = createOption( @@ -1743,6 +1745,7 @@ function getOptions( includeInvoiceRooms = false, includeDomainEmail = false, action, + shouldBoldTitleByDefault = true, }: GetOptionsConfig, ): Options { if (includeCategories) { @@ -1967,7 +1970,7 @@ function getOptions( } reportOption.isSelected = isReportSelected(reportOption, selectedOptions); - reportOption.isBold = shouldUseBoldText(reportOption); + reportOption.isBold = shouldBoldTitleByDefault || shouldUseBoldText(reportOption); if (action === CONST.IOU.ACTION.CATEGORIZE) { const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${reportOption.policyID}`] ?? {}; @@ -1991,7 +1994,7 @@ function getOptions( if (personalDetailsOptionsToExclude.some((optionToExclude) => optionToExclude.login === personalDetailOption.login)) { continue; } - personalDetailOption.isBold = shouldUseBoldText(personalDetailOption); + personalDetailOption.isBold = shouldBoldTitleByDefault; personalDetailsOptions.push(personalDetailOption); } @@ -2061,6 +2064,7 @@ function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] = includeMoneyRequests: true, includeTasks: true, includeSelfDM: true, + shouldBoldTitleByDefault: false, }); Timing.end(CONST.TIMING.LOAD_SEARCH_OPTIONS); Performance.markEnd(CONST.TIMING.LOAD_SEARCH_OPTIONS); @@ -2496,7 +2500,7 @@ function getEmptyOptions(): Options { } function shouldUseBoldText(report: ReportUtils.OptionData): boolean { - return report.isUnread === true && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; + return report.isUnread === true && ReportUtils.getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; } export { diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 0e67dc270518..8c47100e465b 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -20,10 +20,6 @@ function canUseP2PDistanceRequests(betas: OnyxEntry<Beta[]>, iouType: IOUType | return !!betas?.includes(CONST.BETAS.P2P_DISTANCE_REQUESTS) || canUseAllBetas(betas) || iouType === CONST.IOU.TYPE.TRACK; } -function canUseWorkflowsAdvancedApproval(betas: OnyxEntry<Beta[]>): boolean { - return !!betas?.includes(CONST.BETAS.WORKFLOWS_ADVANCED_APPROVAL) || canUseAllBetas(betas); -} - function canUseSpotnanaTravel(betas: OnyxEntry<Beta[]>): boolean { return !!betas?.includes(CONST.BETAS.SPOTNANA_TRAVEL) || canUseAllBetas(betas); } @@ -65,7 +61,6 @@ export default { canUseLinkPreviews, canUseDupeDetection, canUseP2PDistanceRequests, - canUseWorkflowsAdvancedApproval, canUseSpotnanaTravel, canUseWorkspaceFeeds, canUseCompanyCardFeeds, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index f9acb67a1efb..f62935fc6721 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -309,7 +309,7 @@ function getTagNamesFromTagsLists(policyTagLists: PolicyTagLists): string[] { const uniqueTagNames = new Set<string>(); for (const policyTagList of Object.values(policyTagLists ?? {})) { - for (const tag of Object.values(policyTagList.tags)) { + for (const tag of Object.values(policyTagList.tags ?? {})) { uniqueTagNames.add(getCleanedTagName(tag.name)); } } @@ -986,6 +986,10 @@ function getDomainNameForPolicy(policyID?: string): string { return `${CONST.EXPENSIFY_POLICY_DOMAIN}${policyID}${CONST.EXPENSIFY_POLICY_DOMAIN_EXTENSION}`; } +function getWorkflowApprovalsUnavailable(policy: OnyxEntry<Policy>) { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL || !!policy?.errorFields?.approvalMode; +} + export { canEditTaxRate, extractPolicyIDFromPath, @@ -1093,6 +1097,7 @@ export { getAllTaxRatesNamesAndKeys as getAllTaxRates, getTagNamesFromTagsLists, getDomainNameForPolicy, + getWorkflowApprovalsUnavailable, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index b42a23e215bf..49c275278c46 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -36,7 +36,7 @@ function getThumbnailAndImageURIs(transaction: OnyxEntry<Transaction>, receiptPa // filename of uploaded image or last part of remote URI const filename = errors?.filename ?? transaction?.filename ?? receiptFileName ?? ''; const isReceiptImage = Str.isImage(filename); - const hasEReceipt = transaction?.hasEReceipt; + const hasEReceipt = !TransactionUtils.hasReceiptSource(transaction) && transaction?.hasEReceipt; const isReceiptPDF = Str.isPDF(filename); if (hasEReceipt) { diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 4d126cf9cbf4..5a7f6602795c 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -404,6 +404,7 @@ function getCombinedReportActions( transactionThreadReportID: string | null, transactionThreadReportActions: ReportAction[], reportID?: string, + shouldFilterIOUAction = true, ): ReportAction[] { const isSentMoneyReport = reportActions.some((action) => isSentMoneyReportAction(action)); @@ -431,7 +432,7 @@ function getCombinedReportActions( const isSelfDM = report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM; // Filter out request and send money request actions because we don't want to show any preview actions for one transaction reports const filteredReportActions = [...filteredParentReportActions, ...filteredTransactionThreadReportActions].filter((action) => { - if (!isMoneyRequestAction(action)) { + if (!isMoneyRequestAction(action) || !shouldFilterIOUAction) { return true; } const actionType = getOriginalMessage(action)?.type ?? ''; @@ -1648,13 +1649,21 @@ function getPolicyChangeLogDeleteMemberMessage(reportAction: OnyxInputOrEntry<Re } function getRenamedAction(reportAction: OnyxEntry<ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.RENAMED>>) { - const initialMessage = getOriginalMessage(reportAction); + const originalMessage = getOriginalMessage(reportAction); return Localize.translateLocal('newRoomPage.renamedRoomAction', { - oldName: initialMessage?.oldName ?? '', - newName: initialMessage?.newName ?? '', + oldName: originalMessage?.oldName ?? '', + newName: originalMessage?.newName ?? '', }); } +function getRemovedFromApprovalChainMessage(reportAction: OnyxEntry<ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.REMOVED_FROM_APPROVAL_CHAIN>>) { + const originalMessage = getOriginalMessage(reportAction); + const submittersNames = PersonalDetailsUtils.getPersonalDetailsByIDs(originalMessage?.submittersAccountIDs ?? [], currentUserAccountID ?? -1).map( + ({displayName, login}) => displayName ?? login ?? 'Unknown Submitter', + ); + return Localize.translateLocal('workspaceActions.removedFromApprovalWorkflow', {submittersNames}); +} + export { doesReportHaveVisibleActions, extractLinksFromMessageHtml, @@ -1681,6 +1690,7 @@ export { getOneTransactionThreadReportID, getOriginalMessage, getParentReportAction, + getRemovedFromApprovalChainMessage, getReportAction, getReportActionHtml, getReportActionMessage, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7b226b2e5c8e..0e7418a306af 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -55,7 +55,6 @@ import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; import type {Comment, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; -import AccountUtils from './AccountUtils'; import * as IOU from './actions/IOU'; import * as PolicyActions from './actions/Policy/Policy'; import * as store from './actions/ReimbursementAccount/store'; @@ -155,10 +154,10 @@ type OptimisticExpenseReport = Pick< | 'statusNum' | 'total' | 'nonReimbursableTotal' - | 'notificationPreference' | 'parentReportID' | 'lastVisibleActionCreated' | 'parentReportActionID' + | 'participants' | 'fieldList' >; @@ -275,7 +274,6 @@ type OptimisticChatReport = Pick< | 'lastMessageText' | 'lastReadTime' | 'lastVisibleActionCreated' - | 'notificationPreference' | 'oldPolicyName' | 'ownerAccountID' | 'pendingFields' @@ -356,7 +354,6 @@ type OptimisticTaskReport = Pick< | 'policyID' | 'stateNum' | 'statusNum' - | 'notificationPreference' | 'parentReportActionID' | 'lastVisibleActionCreated' | 'hasParentAccess' @@ -396,7 +393,6 @@ type OptimisticIOUReport = Pick< | 'statusNum' | 'total' | 'reportName' - | 'notificationPreference' | 'parentReportID' | 'lastVisibleActionCreated' | 'fieldList' @@ -739,13 +735,10 @@ const unavailableTranslation = Localize.translateLocal('workspace.common.unavail */ function getPolicyName(report: OnyxInputOrEntry<Report>, returnEmptyIfNotFound = false, policy?: OnyxInputOrEntry<Policy>): string { const noPolicyFound = returnEmptyIfNotFound ? '' : unavailableTranslation; - if (isEmptyObject(report)) { + if (isEmptyObject(report) || (isEmptyObject(allPolicies) && !report?.policyName)) { return noPolicyFound; } - if ((!allPolicies || Object.keys(allPolicies).length === 0) && !report?.policyName) { - return unavailableTranslation; - } const finalPolicy = policy ?? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const parentReport = getRootParentReport(report); @@ -1148,6 +1141,32 @@ function isSystemChat(report: OnyxEntry<Report>): boolean { return getChatType(report) === CONST.REPORT.CHAT_TYPE.SYSTEM; } +function getDefaultNotificationPreferenceForReport(report: OnyxEntry<Report>): ValueOf<typeof CONST.REPORT.NOTIFICATION_PREFERENCE> { + if (isAnnounceRoom(report)) { + return CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; + } + if (isPublicRoom(report)) { + return CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY; + } + if (!getChatType(report) || isGroupChat(report)) { + return CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; + } + if (isAdminRoom(report) || isPolicyExpenseChat(report) || isInvoiceRoom(report)) { + return CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; + } + if (isSelfDM(report)) { + return CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; + } + return CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY; +} + +/** + * Get the notification preference given a report + */ +function getReportNotificationPreference(report: OnyxEntry<Report>): ValueOf<typeof CONST.REPORT.NOTIFICATION_PREFERENCE> { + return report?.participants?.[currentUserAccountID ?? -1]?.notificationPreference ?? getDefaultNotificationPreferenceForReport(report); +} + const CONCIERGE_ACCOUNT_ID_STRING = CONST.ACCOUNT_ID.CONCIERGE.toString(); /** * Only returns true if this is our main 1:1 DM report with Concierge. @@ -1253,7 +1272,7 @@ function hasExpensifyGuidesEmails(accountIDs: number[]): boolean { function getMostRecentlyVisitedReport(reports: Array<OnyxEntry<Report>>, reportMetadata: OnyxCollection<ReportMetadata>): OnyxEntry<Report> { const filteredReports = reports.filter((report) => { - const shouldKeep = !isChatThread(report) || report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const shouldKeep = !isChatThread(report) || getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; return shouldKeep && !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime); }); return lodashMaxBy(filteredReports, (a) => new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${a?.reportID}`]?.lastVisitTime ?? a?.lastReadTime ?? '').valueOf()); @@ -1623,13 +1642,6 @@ function isPayer(session: OnyxEntry<Session>, iouReport: OnyxEntry<Report>) { return isAdmin || (isMoneyRequestReport(iouReport) && isManager); } -/** - * Get the notification preference given a report - */ -function getReportNotificationPreference(report: OnyxEntry<Report>): string | number { - return report?.notificationPreference ?? ''; -} - /** * Checks if the current user is the action's author */ @@ -2064,7 +2076,7 @@ function getParticipantsAccountIDsForDisplay(report: OnyxEntry<Report>, shouldEx return false; } - if (shouldExcludeHidden && reportParticipants[accountID]?.hidden) { + if (shouldExcludeHidden && reportParticipants[accountID]?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { return false; } @@ -2117,7 +2129,7 @@ function buildParticipantsFromAccountIDs(accountIDs: number[]): Participants { const finalParticipants: Participants = {}; return accountIDs.reduce((participants, accountID) => { // eslint-disable-next-line no-param-reassign - participants[accountID] = {hidden: false}; + participants[accountID] = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}; return participants; }, finalParticipants); } @@ -4255,8 +4267,8 @@ function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number const policy = getPolicy(policyID); const participants: Participants = { - [payeeAccountID]: {hidden: true}, - [payerAccountID]: {hidden: true}, + [payeeAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + [payerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, }; return { @@ -4274,7 +4286,6 @@ function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number // We don't translate reportName because the server response is always in English reportName: `${payerEmail} owes ${formattedTotal}`, - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, parentReportID: chatReportID, lastVisibleActionCreated: DateUtils.getDBTime(), fieldList: policy?.fieldList, @@ -4329,7 +4340,11 @@ function buildOptimisticInvoiceReport(chatReportID: string, policyID: string, re stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.OPEN, total, - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + participants: { + [currentUserAccountID ?? -1]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + }, parentReportID: chatReportID, lastVisibleActionCreated: DateUtils.getDBTime(), }; @@ -4379,7 +4394,11 @@ function buildOptimisticExpenseReport( statusNum, total: storedTotal, nonReimbursableTotal: reimbursable ? 0 : storedTotal, - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + participants: { + [payeeAccountID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + }, parentReportID: chatReportID, lastVisibleActionCreated: DateUtils.getDBTime(), parentReportActionID, @@ -5039,12 +5058,11 @@ function buildOptimisticChatReport( description = '', avatarUrl = '', optimisticReportID = '', - shouldShowParticipants = true, ): OptimisticChatReport { const isWorkspaceChatType = chatType && isWorkspaceChat(chatType); const participants = participantList.reduce((reportParticipants: Participants, accountID: number) => { const participant: ReportParticipant = { - hidden: !shouldShowParticipants, + notificationPreference, ...(!isWorkspaceChatType && {role: accountID === currentUserAccountID ? CONST.REPORT.ROLE.ADMIN : CONST.REPORT.ROLE.MEMBER}), }; // eslint-disable-next-line no-param-reassign @@ -5065,7 +5083,6 @@ function buildOptimisticChatReport( lastMessageText: undefined, lastReadTime: currentTime, lastVisibleActionCreated: currentTime, - notificationPreference, oldPolicyName, ownerAccountID: ownerAccountID || CONST.REPORT.OWNER_ACCOUNT_ID_FAKE, parentReportActionID, @@ -5482,10 +5499,8 @@ function buildOptimisticWorkspaceChats(policyID: string, policyName: string, exp false, policyName, undefined, - undefined, - - // #announce contains all policy members so notifying always should be opt-in only. - CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY, + CONST.REPORT.WRITE_CAPABILITIES.ADMINS, + CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, ); const announceChatReportID = announceChatData.reportID; const announceCreatedAction = buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE); @@ -5572,12 +5587,12 @@ function buildOptimisticTaskReport( ): OptimisticTaskReport { const participants: Participants = { [ownerAccountID]: { - hidden: false, + notificationPreference, }, }; if (assigneeAccountID) { - participants[assigneeAccountID] = {hidden: false}; + participants[assigneeAccountID] = {notificationPreference}; } return { @@ -5592,7 +5607,6 @@ function buildOptimisticTaskReport( policyID, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, - notificationPreference, lastVisibleActionCreated: DateUtils.getDBTime(), hasParentAccess: true, }; @@ -5670,10 +5684,6 @@ function buildTransactionThread( CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, reportAction?.reportActionID, moneyRequestReport?.reportID, - '', - '', - '', - false, ); } @@ -5865,7 +5875,7 @@ function doesTransactionThreadHaveViolations( return ( TransactionUtils.hasViolation(IOUTransactionID, transactionViolations) || TransactionUtils.hasWarningTypeViolation(IOUTransactionID, transactionViolations) || - TransactionUtils.hasModifiedAmountOrDateViolation(IOUTransactionID, transactionViolations) + (isPaidGroupPolicy(report) && TransactionUtils.hasModifiedAmountOrDateViolation(IOUTransactionID, transactionViolations)) ); } @@ -5981,7 +5991,10 @@ function shouldReportBeInOptionList({ return false; } - if (report?.participants?.[CONST.ACCOUNT_ID.NOTIFICATIONS] && (!currentUserAccountID || !AccountUtils.isAccountIDOddNumber(currentUserAccountID))) { + // We used to use the system DM for A/B testing onboarding tasks, but now only create them in the Concierge chat. We + // still need to allow existing users who have tasks in the system DM to see them, but otherwise we don't need to + // show that chat + if (report?.participants?.[CONST.ACCOUNT_ID.NOTIFICATIONS] && isEmptyReport(report)) { return false; } @@ -6047,7 +6060,7 @@ function shouldReportBeInOptionList({ // All unread chats (even archived ones) in GSD mode will be shown. This is because GSD mode is specifically for focusing the user on the most relevant chats, primarily, the unread ones if (isInFocusMode) { - return isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; + return isUnread(report) && getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; } // Archived reports should always be shown when in default (most recent) mode. This is because you should still be able to access and search for the chats to find them. @@ -6122,7 +6135,7 @@ function getChatByParticipants(newParticipantList: number[], reports: OnyxCollec */ function getInvoiceChatByParticipants(policyID: string, receiverID: string | number, reports: OnyxCollection<Report> = ReportConnection.getAllReports()): OnyxEntry<Report> { return Object.values(reports ?? {}).find((report) => { - if (!report || !isInvoiceRoom(report)) { + if (!report || !isInvoiceRoom(report) || isArchivedRoom(report)) { return false; } @@ -7072,16 +7085,22 @@ function canEditPolicyDescription(policy: OnyxEntry<Policy>): boolean { */ function hasSmartscanError(reportActions: ReportAction[]) { return reportActions.some((action) => { - if (!ReportActionsUtils.isSplitBillAction(action) && !ReportActionsUtils.isReportPreviewAction(action)) { + const isReportPreview = ReportActionsUtils.isReportPreviewAction(action); + const isSplitReportAction = ReportActionsUtils.isSplitBillAction(action); + if (!isSplitReportAction && !isReportPreview) { return false; } const IOUReportID = ReportActionsUtils.getIOUReportIDFromReportActionPreview(action); - const isReportPreviewError = ReportActionsUtils.isReportPreviewAction(action) && shouldShowRBRForMissingSmartscanFields(IOUReportID) && !isSettled(IOUReportID); + const isReportPreviewError = isReportPreview && shouldShowRBRForMissingSmartscanFields(IOUReportID) && !isSettled(IOUReportID); + if (isReportPreviewError) { + return true; + } + const transactionID = ReportActionsUtils.isMoneyRequestAction(action) ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID ?? '-1' : '-1'; const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? {}; - const isSplitBillError = ReportActionsUtils.isSplitBillAction(action) && TransactionUtils.hasMissingSmartscanFields(transaction as Transaction); + const isSplitBillError = isSplitReportAction && TransactionUtils.hasMissingSmartscanFields(transaction as Transaction); - return isReportPreviewError || isSplitBillError; + return isSplitBillError; }); } @@ -7218,7 +7237,7 @@ function shouldDisableThread(reportAction: OnyxInputOrEntry<ReportAction>, repor ); } -function getAllAncestorReportActions(report: Report | null | undefined): Ancestor[] { +function getAllAncestorReportActions(report: Report | null | undefined, currentUpdatedReport?: OnyxEntry<Report>): Ancestor[] { if (!report) { return []; } @@ -7227,7 +7246,7 @@ function getAllAncestorReportActions(report: Report | null | undefined): Ancesto let parentReportActionID = report.parentReportActionID; while (parentReportID) { - const parentReport = getReportOrDraftReport(parentReportID); + const parentReport = currentUpdatedReport && currentUpdatedReport.reportID === parentReportID ? currentUpdatedReport : getReportOrDraftReport(parentReportID); const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID ?? '-1'); if ( @@ -7340,7 +7359,7 @@ function canBeAutoReimbursed(report: OnyxInputOrEntry<Report>, policy: OnyxInput } type CurrencyType = TupleToUnion<typeof CONST.DIRECT_REIMBURSEMENT_CURRENCIES>; const reimbursableTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; - const autoReimbursementLimit = policy.autoReimbursementLimit ?? 0; + const autoReimbursementLimit = policy?.autoReimbursement?.limit ?? policy?.autoReimbursementLimit ?? 0; const isAutoReimbursable = isReportInGroupPolicy(report) && policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && @@ -7439,7 +7458,7 @@ function isAdminOwnerApproverOrReportOwner(report: OnyxEntry<Report>, policy: On /** * Whether the user can join a report */ -function canJoinChat(report: OnyxInputOrEntry<Report>, parentReportAction: OnyxInputOrEntry<ReportAction>, policy: OnyxInputOrEntry<Policy>): boolean { +function canJoinChat(report: OnyxEntry<Report>, parentReportAction: OnyxInputOrEntry<ReportAction>, policy: OnyxInputOrEntry<Policy>): boolean { // We disabled thread functions for whisper action // So we should not show join option for existing thread on whisper message that has already been left, or manually leave it if (ReportActionsUtils.isWhisperAction(parentReportAction)) { @@ -7447,7 +7466,7 @@ function canJoinChat(report: OnyxInputOrEntry<Report>, parentReportAction: OnyxI } // If the notification preference of the chat is not hidden that means we have already joined the chat - if (report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + if (getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { return false; } @@ -7481,7 +7500,7 @@ function canLeaveChat(report: OnyxEntry<Report>, policy: OnyxEntry<Policy>): boo return false; } - if (report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + if (getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { return false; } @@ -7499,7 +7518,7 @@ function canLeaveChat(report: OnyxEntry<Report>, policy: OnyxEntry<Policy>): boo return canLeaveInvoiceRoom(report); } - return (isChatThread(report) && !!report?.notificationPreference?.length) || isUserCreatedPolicyRoom(report) || isNonAdminOrOwnerOfPolicyExpenseChat(report, policy); + return (isChatThread(report) && !!getReportNotificationPreference(report)) || isUserCreatedPolicyRoom(report) || isNonAdminOrOwnerOfPolicyExpenseChat(report, policy); } function getReportActionActorAccountID(reportAction: OnyxInputOrEntry<ReportAction>, iouReport: OnyxInputOrEntry<Report> | undefined): number | undefined { @@ -7619,8 +7638,9 @@ function shouldShowMerchantColumn(transactions: Transaction[]) { } /** - * Whether the report is a system chat or concierge chat, depending on the onboarding report ID or fallbacking - * to the user's account ID (used for A/B testing purposes). + * Whether a given report is used for onboarding tasks. In the past, it could be either the Concierge chat or the system + * DM, and we saved the report ID in the user's `onboarding` NVP. As a fallback for users who don't have the NVP, we now + * only use the Concierge chat. */ function isChatUsedForOnboarding(optionOrReport: OnyxEntry<Report> | OptionData): boolean { // onboarding can be an array for old accounts and accounts created from olddot @@ -7628,13 +7648,12 @@ function isChatUsedForOnboarding(optionOrReport: OnyxEntry<Report> | OptionData) return onboarding.chatReportID === optionOrReport?.reportID; } - return AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? -1) - ? isSystemChat(optionOrReport) - : (optionOrReport as OptionData)?.isConciergeChat ?? isConciergeChatReport(optionOrReport); + return (optionOrReport as OptionData)?.isConciergeChat ?? isConciergeChatReport(optionOrReport); } /** - * Get the report (system or concierge chat) used for the user's onboarding process. + * Get the report used for the user's onboarding process. For most users it is the Concierge chat, however in the past + * we also used the system DM for A/B tests. */ function getChatUsedForOnboarding(): OnyxEntry<Report> { return Object.values(ReportConnection.getAllReports() ?? {}).find(isChatUsedForOnboarding); @@ -7775,8 +7794,8 @@ function hasMissingInvoiceBankAccount(iouReportID: string): boolean { return invoiceReport?.ownerAccountID === currentUserAccountID && isEmptyObject(getPolicy(invoiceReport?.policyID)?.invoice?.bankAccount ?? {}) && isSettled(iouReportID); } -function isSubmittedExpenseReportManagerWithoutParentAccess(report: OnyxEntry<Report>) { - return isExpenseReport(report) && report?.hasParentAccess === false && report?.managerID === currentUserAccountID && isProcessingReport(report); +function isExpenseReportManagerWithoutParentAccess(report: OnyxEntry<Report>) { + return isExpenseReport(report) && report?.hasParentAccess === false && report?.managerID === currentUserAccountID; } export { @@ -7975,7 +7994,7 @@ export { isEmptyReport, isRootGroupChat, isExpenseReport, - isSubmittedExpenseReportManagerWithoutParentAccess, + isExpenseReportManagerWithoutParentAccess, isExpenseRequest, isExpensifyOnlyParticipantInReport, isGroupChat, @@ -8032,6 +8051,7 @@ export { isInvoiceRoomWithID, isInvoiceReport, isOpenInvoiceReport, + getDefaultNotificationPreferenceForReport, canWriteInReport, navigateToDetailsPage, navigateToPrivateNotes, diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index a93e3fae8551..622c3f5f3c4a 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -3,6 +3,11 @@ // https://peggyjs.org/ + + function buildFilter(operator, left, right) { + return { operator, left, right }; + } + function peg$subclass(child, parent) { function C() { this.constructor = child; } C.prototype = parent.prototype; @@ -176,148 +181,129 @@ function peg$parse(input, options) { var peg$startRuleFunction = peg$parsequery; var peg$c0 = "!="; - var peg$c1 = ">"; - var peg$c2 = ">="; - var peg$c3 = "<"; - var peg$c4 = "<="; - var peg$c5 = "type"; - var peg$c6 = "status"; - var peg$c7 = "date"; - var peg$c8 = "amount"; - var peg$c9 = "expenseType"; - var peg$c10 = "in"; - var peg$c11 = "currency"; - var peg$c12 = "merchant"; - var peg$c13 = "description"; - var peg$c14 = "from"; + var peg$c1 = ">="; + var peg$c2 = ">"; + var peg$c3 = "<="; + var peg$c4 = "<"; + var peg$c5 = "date"; + var peg$c6 = "amount"; + var peg$c7 = "merchant"; + var peg$c8 = "description"; + var peg$c9 = "reportID"; + var peg$c10 = "keyword"; + var peg$c11 = "in"; + var peg$c12 = "currency"; + var peg$c13 = "tag"; + var peg$c14 = "category"; var peg$c15 = "to"; - var peg$c16 = "category"; - var peg$c17 = "tag"; - var peg$c18 = "taxRate"; - var peg$c19 = "cardID"; - var peg$c20 = "reportID"; - var peg$c21 = "keyword"; + var peg$c16 = "taxRate"; + var peg$c17 = "cardID"; + var peg$c18 = "from"; + var peg$c19 = "expenseType"; + var peg$c20 = "type"; + var peg$c21 = "status"; var peg$c22 = "sortBy"; var peg$c23 = "sortOrder"; var peg$c24 = "policyID"; - var peg$c25 = "has"; - var peg$c26 = "\""; + var peg$c25 = "\""; var peg$r0 = /^[:=]/; var peg$r1 = /^[^"\r\n]/; var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',;]/; var peg$r3 = /^[ \t\r\n]/; - var peg$e0 = peg$classExpectation([":", "="], false, false); - var peg$e1 = peg$literalExpectation("!=", false); - var peg$e2 = peg$literalExpectation(">", false); + var peg$e0 = peg$otherExpectation("operator"); + var peg$e1 = peg$classExpectation([":", "="], false, false); + var peg$e2 = peg$literalExpectation("!=", false); var peg$e3 = peg$literalExpectation(">=", false); - var peg$e4 = peg$literalExpectation("<", false); + var peg$e4 = peg$literalExpectation(">", false); var peg$e5 = peg$literalExpectation("<=", false); - var peg$e6 = peg$literalExpectation("type", false); - var peg$e7 = peg$literalExpectation("status", false); + var peg$e6 = peg$literalExpectation("<", false); + var peg$e7 = peg$otherExpectation("key"); var peg$e8 = peg$literalExpectation("date", false); var peg$e9 = peg$literalExpectation("amount", false); - var peg$e10 = peg$literalExpectation("expenseType", false); - var peg$e11 = peg$literalExpectation("in", false); - var peg$e12 = peg$literalExpectation("currency", false); - var peg$e13 = peg$literalExpectation("merchant", false); - var peg$e14 = peg$literalExpectation("description", false); - var peg$e15 = peg$literalExpectation("from", false); - var peg$e16 = peg$literalExpectation("to", false); + var peg$e10 = peg$literalExpectation("merchant", false); + var peg$e11 = peg$literalExpectation("description", false); + var peg$e12 = peg$literalExpectation("reportID", false); + var peg$e13 = peg$literalExpectation("keyword", false); + var peg$e14 = peg$literalExpectation("in", false); + var peg$e15 = peg$literalExpectation("currency", false); + var peg$e16 = peg$literalExpectation("tag", false); var peg$e17 = peg$literalExpectation("category", false); - var peg$e18 = peg$literalExpectation("tag", false); + var peg$e18 = peg$literalExpectation("to", false); var peg$e19 = peg$literalExpectation("taxRate", false); var peg$e20 = peg$literalExpectation("cardID", false); - var peg$e21 = peg$literalExpectation("reportID", false); - var peg$e22 = peg$literalExpectation("keyword", false); - var peg$e23 = peg$literalExpectation("sortBy", false); - var peg$e24 = peg$literalExpectation("sortOrder", false); - var peg$e25 = peg$literalExpectation("policyID", false); - var peg$e26 = peg$literalExpectation("has", false); - var peg$e27 = peg$literalExpectation("\"", false); - var peg$e28 = peg$classExpectation(["\"", "\r", "\n"], true, false); - var peg$e29 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";"], false, false); - var peg$e30 = peg$otherExpectation("whitespace"); - var peg$e31 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); - - var peg$f0 = function(filters) { - const withDefaults = applyDefaults(filters); - if (defaultValues.policyID) { - return applyPolicyID(withDefaults); - } - - return withDefaults; - }; + var peg$e21 = peg$literalExpectation("from", false); + var peg$e22 = peg$literalExpectation("expenseType", false); + var peg$e23 = peg$otherExpectation("default key"); + var peg$e24 = peg$literalExpectation("type", false); + var peg$e25 = peg$literalExpectation("status", false); + var peg$e26 = peg$literalExpectation("sortBy", false); + var peg$e27 = peg$literalExpectation("sortOrder", false); + var peg$e28 = peg$literalExpectation("policyID", false); + var peg$e29 = peg$otherExpectation("quote"); + var peg$e30 = peg$literalExpectation("\"", false); + var peg$e31 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e32 = peg$otherExpectation("word"); + var peg$e33 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";"], false, false); + var peg$e34 = peg$otherExpectation("whitespace"); + var peg$e35 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); + + var peg$f0 = function(filters) { return applyDefaults(filters); }; var peg$f1 = function(head, tail) { - const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null); + const allFilters = [head, ...tail.map(([_, filter]) => filter)] + .filter(Boolean) + .filter((filter) => filter.right); if (!allFilters.length) { - return null; - } - const keywords = allFilters.filter((filter) => filter.left === "keyword" || filter.right?.left === "keyword") - const nonKeywords = allFilters.filter((filter) => filter.left !== "keyword" && filter.right?.left !== "keyword") - if(!nonKeywords.length){ - return keywords.reduce((result, filter) => buildFilter("or", result, filter)) - } - if(!keywords.length){ - return nonKeywords.reduce((result, filter) => buildFilter("and", result, filter)) - } - - return buildFilter("and", keywords.reduce((result, filter) => buildFilter("or", result, filter)), nonKeywords.reduce((result, filter) => buildFilter("and", result, filter))) - - return allFilters.reduce((result, filter) => buildFilter("and", result, filter)); - }; - var peg$f2 = function(field, op, value) { - if (isDefaultField(field)) { - updateDefaultValues(field, value.trim()); return null; } - if (isPolicyID(field)) { - updateDefaultValues(field, value.trim()); - return null; + const keywords = allFilters.filter( + (filter) => + filter.left === "keyword" || filter.right?.left === "keyword" + ); + const nonKeywords = allFilters.filter( + (filter) => + filter.left !== "keyword" && filter.right?.left !== "keyword" + ); + + const keywordFilter = buildFilter( + "eq", + "keyword", + keywords.map((filter) => filter.right).flat() + ); + if (keywordFilter.right.length > 0) { + nonKeywords.push(keywordFilter); } - - if (!field && !op) { - return buildFilter('eq', 'keyword', value.trim()); + return nonKeywords.reduce((result, filter) => + buildFilter("and", result, filter) + ); + }; + var peg$f2 = function(key, op, value) { + updateDefaultValues(key, value); + }; + var peg$f3 = function(value) { return buildFilter("eq", "keyword", value); }; + var peg$f4 = function(field, op, values) { + return buildFilter(op, field, values); + }; + var peg$f5 = function() { return "eq"; }; + var peg$f6 = function() { return "neq"; }; + var peg$f7 = function() { return "gte"; }; + var peg$f8 = function() { return "gt"; }; + var peg$f9 = function() { return "lte"; }; + var peg$f10 = function() { return "lt"; }; + var peg$f11 = function(parts) { + const value = parts.flat(); + if (value.length > 1) { + return value; } - - const values = value.split(','); - const operatorValue = op ?? 'eq'; - - return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0])); + return value[0]; }; - var peg$f3 = function() { return "eq"; }; - var peg$f4 = function() { return "neq"; }; - var peg$f5 = function() { return "gt"; }; - var peg$f6 = function() { return "gte"; }; - var peg$f7 = function() { return "lt"; }; - var peg$f8 = function() { return "lte"; }; - var peg$f9 = function() { return "type"; }; - var peg$f10 = function() { return "status"; }; - var peg$f11 = function() { return "date"; }; - var peg$f12 = function() { return "amount"; }; - var peg$f13 = function() { return "expenseType"; }; - var peg$f14 = function() { return "in"; }; - var peg$f15 = function() { return "currency"; }; - var peg$f16 = function() { return "merchant"; }; - var peg$f17 = function() { return "description"; }; - var peg$f18 = function() { return "from"; }; - var peg$f19 = function() { return "to"; }; - var peg$f20 = function() { return "category"; }; - var peg$f21 = function() { return "tag"; }; - var peg$f22 = function() { return "taxRate"; }; - var peg$f23 = function() { return "cardID"; }; - var peg$f24 = function() { return "reportID"; }; - var peg$f25 = function() { return "keyword"; }; - var peg$f26 = function() { return "sortBy"; }; - var peg$f27 = function() { return "sortOrder"; }; - var peg$f28 = function() { return "policyID"; }; - var peg$f29 = function() { return "has"; }; - var peg$f30 = function(parts) { return parts.join(''); }; - var peg$f31 = function(chars) { return chars.join(''); }; - var peg$f32 = function(chars) { return chars.join(''); }; - var peg$f33 = function() { return "and"; }; + var peg$f12 = function(chars) { return chars.join(""); }; + var peg$f13 = function(chars) { + return chars.join("").trim().split(",").filter(Boolean); + }; + var peg$f14 = function() { return "and"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -537,24 +523,98 @@ function peg$parse(input, options) { } function peg$parsefilter() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parsestandardFilter(); + if (s1 === peg$FAILED) { + s1 = peg$parsedefaultFilter(); + if (s1 === peg$FAILED) { + s1 = peg$parsefreeTextFilter(); + } + } + if (s1 !== peg$FAILED) { + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsedefaultFilter() { var s0, s1, s2, s3, s4, s5, s6; s0 = peg$currPos; s1 = peg$parse_(); - s2 = peg$parsekey(); - if (s2 === peg$FAILED) { - s2 = null; - } - s3 = peg$parse_(); - s4 = peg$parseoperator(); - if (s4 === peg$FAILED) { - s4 = null; + s2 = peg$parsedefaultKey(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + s4 = peg$parseoperator(); + if (s4 !== peg$FAILED) { + s5 = peg$parse_(); + s6 = peg$parseidentifier(); + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f2(s2, s4, s6); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; } - s5 = peg$parse_(); - s6 = peg$parseidentifier(); - if (s6 !== peg$FAILED) { + + return s0; + } + + function peg$parsefreeTextFilter() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parseidentifier(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); peg$savedPos = s0; - s0 = peg$f2(s2, s4, s6); + s0 = peg$f3(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsestandardFilter() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parsekey(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + s4 = peg$parseoperator(); + if (s4 !== peg$FAILED) { + s5 = peg$parse_(); + s6 = peg$parseidentifier(); + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f4(s2, s4, s6); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } } else { peg$currPos = s0; s0 = peg$FAILED; @@ -566,17 +626,18 @@ function peg$parse(input, options) { function peg$parseoperator() { var s0, s1; + peg$silentFails++; s0 = peg$currPos; s1 = input.charAt(peg$currPos); if (peg$r0.test(s1)) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e0); } + if (peg$silentFails === 0) { peg$fail(peg$e1); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f3(); + s1 = peg$f5(); } s0 = s1; if (s0 === peg$FAILED) { @@ -586,67 +647,67 @@ function peg$parse(input, options) { peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e1); } + if (peg$silentFails === 0) { peg$fail(peg$e2); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f4(); + s1 = peg$f6(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { + if (input.substr(peg$currPos, 2) === peg$c1) { s1 = peg$c1; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e2); } + if (peg$silentFails === 0) { peg$fail(peg$e3); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f5(); + s1 = peg$f7(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c2) { + if (input.charCodeAt(peg$currPos) === 62) { s1 = peg$c2; - peg$currPos += 2; + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e3); } + if (peg$silentFails === 0) { peg$fail(peg$e4); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f6(); + s1 = peg$f8(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { + if (input.substr(peg$currPos, 2) === peg$c3) { s1 = peg$c3; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e4); } + if (peg$silentFails === 0) { peg$fail(peg$e5); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f7(); + s1 = peg$f9(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c4) { + if (input.charCodeAt(peg$currPos) === 60) { s1 = peg$c4; - peg$currPos += 2; + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e5); } + if (peg$silentFails === 0) { peg$fail(peg$e6); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f8(); + s1 = peg$f10(); } s0 = s1; } @@ -654,6 +715,11 @@ function peg$parse(input, options) { } } } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } return s0; } @@ -661,303 +727,134 @@ function peg$parse(input, options) { function peg$parsekey() { var s0, s1; + peg$silentFails++; s0 = peg$currPos; if (input.substr(peg$currPos, 4) === peg$c5) { s1 = peg$c5; peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e6); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f9(); + if (peg$silentFails === 0) { peg$fail(peg$e8); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; + if (s1 === peg$FAILED) { if (input.substr(peg$currPos, 6) === peg$c6) { s1 = peg$c6; peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e7); } + if (peg$silentFails === 0) { peg$fail(peg$e9); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f10(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 4) === peg$c7) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c7) { s1 = peg$c7; - peg$currPos += 4; + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e8); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f11(); + if (peg$silentFails === 0) { peg$fail(peg$e10); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 6) === peg$c8) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 11) === peg$c8) { s1 = peg$c8; - peg$currPos += 6; + peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e9); } + if (peg$silentFails === 0) { peg$fail(peg$e11); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f12(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 11) === peg$c9) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c9) { s1 = peg$c9; - peg$currPos += 11; + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e10); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f13(); + if (peg$silentFails === 0) { peg$fail(peg$e12); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c10) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 7) === peg$c10) { s1 = peg$c10; - peg$currPos += 2; + peg$currPos += 7; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e11); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f14(); + if (peg$silentFails === 0) { peg$fail(peg$e13); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 8) === peg$c11) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 2) === peg$c11) { s1 = peg$c11; - peg$currPos += 8; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e12); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f15(); + if (peg$silentFails === 0) { peg$fail(peg$e14); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; + if (s1 === peg$FAILED) { if (input.substr(peg$currPos, 8) === peg$c12) { s1 = peg$c12; peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e13); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f16(); + if (peg$silentFails === 0) { peg$fail(peg$e15); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 11) === peg$c13) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 3) === peg$c13) { s1 = peg$c13; - peg$currPos += 11; + peg$currPos += 3; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e14); } + if (peg$silentFails === 0) { peg$fail(peg$e16); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f17(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 4) === peg$c14) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c14) { s1 = peg$c14; - peg$currPos += 4; + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e15); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f18(); + if (peg$silentFails === 0) { peg$fail(peg$e17); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; + if (s1 === peg$FAILED) { if (input.substr(peg$currPos, 2) === peg$c15) { s1 = peg$c15; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e16); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f19(); + if (peg$silentFails === 0) { peg$fail(peg$e18); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 8) === peg$c16) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 7) === peg$c16) { s1 = peg$c16; - peg$currPos += 8; + peg$currPos += 7; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e17); } + if (peg$silentFails === 0) { peg$fail(peg$e19); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f20(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 3) === peg$c17) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c17) { s1 = peg$c17; - peg$currPos += 3; + peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e18); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f21(); + if (peg$silentFails === 0) { peg$fail(peg$e20); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 7) === peg$c18) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 4) === peg$c18) { s1 = peg$c18; - peg$currPos += 7; + peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e19); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f22(); + if (peg$silentFails === 0) { peg$fail(peg$e21); } } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 6) === peg$c19) { + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 11) === peg$c19) { s1 = peg$c19; - peg$currPos += 6; + peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } + if (peg$silentFails === 0) { peg$fail(peg$e22); } } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f23(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 8) === peg$c20) { - s1 = peg$c20; - peg$currPos += 8; + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 2) === peg$c11) { + s1 = peg$c11; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f24(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 7) === peg$c21) { - s1 = peg$c21; - peg$currPos += 7; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f25(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 6) === peg$c22) { - s1 = peg$c22; - peg$currPos += 6; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f26(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 9) === peg$c23) { - s1 = peg$c23; - peg$currPos += 9; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f27(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 8) === peg$c24) { - s1 = peg$c24; - peg$currPos += 8; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e25); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f28(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 3) === peg$c25) { - s1 = peg$c25; - peg$currPos += 3; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f29(); - } - s0 = s1; - } - } - } - } + if (peg$silentFails === 0) { peg$fail(peg$e14); } } } } @@ -974,6 +871,80 @@ function peg$parse(input, options) { } } } + if (s1 !== peg$FAILED) { + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + + return s0; + } + + function peg$parsedefaultKey() { + var s0, s1; + + peg$silentFails++; + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c20) { + s1 = peg$c20; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c21) { + s1 = peg$c21; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e25); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c22) { + s1 = peg$c22; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e26); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 9) === peg$c23) { + s1 = peg$c23; + peg$currPos += 9; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e27); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c24) { + s1 = peg$c24; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e28); } + } + } + } + } + } + if (s1 !== peg$FAILED) { + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e23); } + } return s0; } @@ -1000,7 +971,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f30(s1); + s1 = peg$f11(s1); } s0 = s1; @@ -1010,13 +981,14 @@ function peg$parse(input, options) { function peg$parsequotedString() { var s0, s1, s2, s3; + peg$silentFails++; s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 34) { - s1 = peg$c26; + s1 = peg$c25; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e27); } + if (peg$silentFails === 0) { peg$fail(peg$e30); } } if (s1 !== peg$FAILED) { s2 = []; @@ -1025,7 +997,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e28); } + if (peg$silentFails === 0) { peg$fail(peg$e31); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -1034,19 +1006,19 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e28); } + if (peg$silentFails === 0) { peg$fail(peg$e31); } } } if (input.charCodeAt(peg$currPos) === 34) { - s3 = peg$c26; + s3 = peg$c25; peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e27); } + if (peg$silentFails === 0) { peg$fail(peg$e30); } } if (s3 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f31(s2); + s0 = peg$f12(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1055,6 +1027,11 @@ function peg$parse(input, options) { peg$currPos = s0; s0 = peg$FAILED; } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e29); } + } return s0; } @@ -1062,6 +1039,7 @@ function peg$parse(input, options) { function peg$parsealphanumeric() { var s0, s1, s2; + peg$silentFails++; s0 = peg$currPos; s1 = []; s2 = input.charAt(peg$currPos); @@ -1069,7 +1047,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e29); } + if (peg$silentFails === 0) { peg$fail(peg$e33); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -1079,7 +1057,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e29); } + if (peg$silentFails === 0) { peg$fail(peg$e33); } } } } else { @@ -1087,9 +1065,14 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f32(s1); + s1 = peg$f13(s1); } s0 = s1; + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e32); } + } return s0; } @@ -1100,7 +1083,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f33(); + s1 = peg$f14(); s0 = s1; return s0; @@ -1116,7 +1099,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e31); } + if (peg$silentFails === 0) { peg$fail(peg$e35); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -1125,39 +1108,28 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e31); } + if (peg$silentFails === 0) { peg$fail(peg$e35); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e30); } + if (peg$silentFails === 0) { peg$fail(peg$e34); } return s0; } const defaultValues = { - "type": "expense", - "status": "all", - "sortBy": "date", - "sortOrder": "desc", + type: "expense", + status: "all", + sortBy: "date", + sortOrder: "desc", }; - function buildFilter(operator, left, right) { - return { operator, left, right }; - } - function applyDefaults(filters) { return { ...defaultValues, - filters - }; - } - - function applyPolicyID(filtersWithDefaults) { - return { - ...filtersWithDefaults, - policyID: filtersWithDefaults.policyID + filters, }; } @@ -1165,14 +1137,6 @@ function peg$parse(input, options) { defaultValues[field] = value; } - function isDefaultField(field) { - return defaultValues.hasOwnProperty(field); - } - - function isPolicyID(field) { - return field === 'policyID'; - } - peg$result = peg$startRuleFunction(); if (options.peg$library) { diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy index 105a8a62bc39..f9f681736c61 100644 --- a/src/libs/SearchParser/searchParser.peggy +++ b/src/libs/SearchParser/searchParser.peggy @@ -5,148 +5,142 @@ // // query: entry point for the parser and rule to process the values returned by the filterList rule. Takes filters as an argument and returns the final AST output. // filterList: rule to process the array of filters returned by the filter rule. It takes head and tail as arguments, filters it for null values and builds the AST. -// filter: rule to build the filter object. It takes field, operator and value as input and returns {operator, left: field, right: value} or null if the left value is a defaultValues +// filter: abstract rule to simplify the filterList rule. It takes all filter types. +// defaultFilter: rule to process the default values returned by the defaultKey rule. It updates the default values object. +// freeTextFilter: rule to process the free text search values returned by the identifier rule. It builds filter Object. +// standardFilter: rule to process the values returned by the key rule. It builds filter Object. // operator: rule to match pre-defined search syntax operators, e.g. !=, >, etc // key: rule to match pre-defined search syntax fields, e.g. amount, merchant, etc +// defaultKey: rule to match pre-defined search syntax fields that are used to update default values, e.g. type, status, etc // identifier: composite rule to match patterns defined by the quotedString and alphanumeric rules // quotedString: rule to match a quoted string pattern, e.g. "this is a quoted string" // alphanumeric: rule to match unquoted alphanumeric characters, e.g. a-z, 0-9, _, @, etc // logicalAnd: rule to match whitespace and return it as a logical 'and' operator // whitespace: rule to match whitespaces -{ - const defaultValues = { - "type": "expense", - "status": "all", - "sortBy": "date", - "sortOrder": "desc", - }; - +// global initializer (code executed only once) +{{ function buildFilter(operator, left, right) { return { operator, left, right }; } +}} + +// per-parser initializer (code executed before every parse) +{ + const defaultValues = { + type: "expense", + status: "all", + sortBy: "date", + sortOrder: "desc", + }; function applyDefaults(filters) { return { ...defaultValues, - filters - }; - } - - function applyPolicyID(filtersWithDefaults) { - return { - ...filtersWithDefaults, - policyID: filtersWithDefaults.policyID + filters, }; } function updateDefaultValues(field, value) { defaultValues[field] = value; } - - function isDefaultField(field) { - return defaultValues.hasOwnProperty(field); - } - - function isPolicyID(field) { - return field === 'policyID'; - } } -query - = _ filters:filterList? _ { - const withDefaults = applyDefaults(filters); - if (defaultValues.policyID) { - return applyPolicyID(withDefaults); - } - - return withDefaults; - } +query = _ filters:filterList? _ { return applyDefaults(filters); } filterList = head:filter tail:(logicalAnd filter)* { - const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null); + const allFilters = [head, ...tail.map(([_, filter]) => filter)] + .filter(Boolean) + .filter((filter) => filter.right); if (!allFilters.length) { - return null; - } - const keywords = allFilters.filter((filter) => filter.left === "keyword" || filter.right?.left === "keyword") - const nonKeywords = allFilters.filter((filter) => filter.left !== "keyword" && filter.right?.left !== "keyword") - if(!nonKeywords.length){ - return keywords.reduce((result, filter) => buildFilter("or", result, filter)) - } - if(!keywords.length){ - return nonKeywords.reduce((result, filter) => buildFilter("and", result, filter)) - } - - return buildFilter("and", keywords.reduce((result, filter) => buildFilter("or", result, filter)), nonKeywords.reduce((result, filter) => buildFilter("and", result, filter))) - - return allFilters.reduce((result, filter) => buildFilter("and", result, filter)); - } - -filter - = _ field:key? _ op:operator? _ value:identifier { - if (isDefaultField(field)) { - updateDefaultValues(field, value.trim()); return null; } - if (isPolicyID(field)) { - updateDefaultValues(field, value.trim()); - return null; + const keywords = allFilters.filter( + (filter) => + filter.left === "keyword" || filter.right?.left === "keyword" + ); + const nonKeywords = allFilters.filter( + (filter) => + filter.left !== "keyword" && filter.right?.left !== "keyword" + ); + + const keywordFilter = buildFilter( + "eq", + "keyword", + keywords.map((filter) => filter.right).flat() + ); + if (keywordFilter.right.length > 0) { + nonKeywords.push(keywordFilter); } + return nonKeywords.reduce((result, filter) => + buildFilter("and", result, filter) + ); + } - if (!field && !op) { - return buildFilter('eq', 'keyword', value.trim()); - } +filter = @(standardFilter / defaultFilter / freeTextFilter) - const values = value.split(','); - const operatorValue = op ?? 'eq'; +defaultFilter + = _ key:defaultKey _ op:operator _ value:identifier { + updateDefaultValues(key, value); + } - return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0])); +freeTextFilter + = _ value:identifier _ { return buildFilter("eq", "keyword", value); } + +standardFilter + = _ field:key _ op:operator _ values:identifier { + return buildFilter(op, field, values); } -operator +operator "operator" = (":" / "=") { return "eq"; } / "!=" { return "neq"; } - / ">" { return "gt"; } / ">=" { return "gte"; } - / "<" { return "lt"; } + / ">" { return "gt"; } / "<=" { return "lte"; } + / "<" { return "lt"; } -key - = "type" { return "type"; } - / "status" { return "status"; } - / "date" { return "date"; } - / "amount" { return "amount"; } - / "expenseType" { return "expenseType"; } - / "in" { return "in"; } - / "currency" { return "currency"; } - / "merchant" { return "merchant"; } - / "description" { return "description"; } - / "from" { return "from"; } - / "to" { return "to"; } - / "category" { return "category"; } - / "tag" { return "tag"; } - / "taxRate" { return "taxRate"; } - / "cardID" { return "cardID"; } - / "reportID" { return "reportID"; } - / "keyword" { return "keyword"; } - / "sortBy" { return "sortBy"; } - / "sortOrder" { return "sortOrder"; } - / "policyID" { return "policyID"; } - / "has" { return "has"; } +key "key" + = @( + "date" + / "amount" + / "merchant" + / "description" + / "reportID" + / "keyword" + / "in" + / "currency" + / "tag" + / "category" + / "to" + / "taxRate" + / "cardID" + / "from" + / "expenseType" + / "in" + ) + +defaultKey "default key" + = @("type" / "status" / "sortBy" / "sortOrder" / "policyID") identifier - = parts:(quotedString / alphanumeric)+ { return parts.join(''); } + = parts:(quotedString / alphanumeric)+ { + const value = parts.flat(); + if (value.length > 1) { + return value; + } + return value[0]; + } -quotedString - = '"' chars:[^"\r\n]* '"' { return chars.join(''); } +quotedString "quote" = "\"" chars:[^"\r\n]* "\"" { return chars.join(""); } -alphanumeric - = chars:[A-Za-z0-9_@./#&+\-\\',;]+ { return chars.join(''); } +alphanumeric "word" + = chars:[A-Za-z0-9_@./#&+\-\\',;]+ { + return chars.join("").trim().split(",").filter(Boolean); + } -logicalAnd - = _ { return "and"; } +logicalAnd = _ { return "and"; } -_ "whitespace" - = [ \t\r\n]* +_ "whitespace" = [ \t\r\n]* diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 790097597e6a..feb7bd4d5857 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -1,8 +1,10 @@ +import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {ASTNode, QueryFilter, QueryFilters, SearchColumnType, SearchQueryJSON, SearchQueryString, SearchStatus, SortOrder} from '@components/Search/types'; +import ChatListItem from '@components/SelectionList/ChatListItem'; import ReportListItem from '@components/SelectionList/Search/ReportListItem'; import TransactionListItem from '@components/SelectionList/Search/TransactionListItem'; -import type {ListItem, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ListItem, ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -17,11 +19,14 @@ import DateUtils from './DateUtils'; import {translateLocal} from './Localize'; import navigationRef from './Navigation/navigationRef'; import type {AuthScreensParamList, RootStackParamList, State} from './Navigation/types'; +import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as ReportActionsUtils from './ReportActionsUtils'; +import * as ReportUtils from './ReportUtils'; import * as searchParser from './SearchParser/searchParser'; import * as TransactionUtils from './TransactionUtils'; import * as UserUtils from './UserUtils'; -type KeysOfFilterKeysObject = keyof typeof CONST.SEARCH.SYNTAX_FILTER_KEYS; +type FilterKeys = keyof typeof CONST.SEARCH.SYNTAX_FILTER_KEYS; const columnNamesToSortingProperty = { [CONST.SEARCH.TABLE_COLUMNS.TO]: 'formattedTo' as const, @@ -76,10 +81,32 @@ function getTransactionItemCommonFormattedProperties( }; } +type ReportKey = `${typeof ONYXKEYS.COLLECTION.REPORT}${string}`; + +type TransactionKey = `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`; + +type ReportActionKey = `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS}${string}`; + +function isReportEntry(key: string): key is ReportKey { + return key.startsWith(ONYXKEYS.COLLECTION.REPORT); +} + +function isTransactionEntry(key: string): key is TransactionKey { + return key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION); +} + +function isReportActionEntry(key: string): key is ReportActionKey { + return key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS); +} + function getShouldShowMerchant(data: OnyxTypes.SearchResults['data']): boolean { - return Object.values(data).some((item) => { - const merchant = item.modifiedMerchant ? item.modifiedMerchant : item.merchant ?? ''; - return merchant !== '' && merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + return Object.keys(data).some((key) => { + if (isTransactionEntry(key)) { + const item = data[key]; + const merchant = item.modifiedMerchant ? item.modifiedMerchant : item.merchant ?? ''; + return merchant !== '' && merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + } + return false; }); } @@ -89,11 +116,16 @@ function isReportListItemType(item: ListItem): item is ReportListItemType { return 'transactions' in item; } -function isTransactionListItemType(item: TransactionListItemType | ReportListItemType): item is TransactionListItemType { +function isTransactionListItemType(item: TransactionListItemType | ReportListItemType | ReportActionListItemType): item is TransactionListItemType { const transactionListItem = item as TransactionListItemType; return transactionListItem.transactionID !== undefined; } +function isReportActionListItemType(item: TransactionListItemType | ReportListItemType | ReportActionListItemType): item is ReportActionListItemType { + const reportActionListItem = item as ReportActionListItemType; + return reportActionListItem.reportActionID !== undefined; +} + function shouldShowYear(data: TransactionListItemType[] | ReportListItemType[] | OnyxTypes.SearchResults['data']): boolean { if (Array.isArray(data)) { return data.some((item: TransactionListItemType | ReportListItemType) => { @@ -110,14 +142,23 @@ function shouldShowYear(data: TransactionListItemType[] | ReportListItemType[] | }); } - for (const [key, transactionItem] of Object.entries(data)) { - if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { - const item = transactionItem as SearchTransaction; + for (const key in data) { + if (isTransactionEntry(key)) { + const item = data[key]; const date = TransactionUtils.getCreated(item); if (DateUtils.doesDateBelongToAPastYear(date)) { return true; } + } else if (isReportActionEntry(key)) { + const item = data[key]; + for (const action of Object.values(item)) { + const date = action.created; + + if (DateUtils.doesDateBelongToAPastYear(date)) { + return true; + } + } } } return false; @@ -128,9 +169,10 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata const doesDataContainAPastYearTransaction = shouldShowYear(data); - return Object.entries(data) - .filter(([key]) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) - .map(([, transactionItem]) => { + return Object.keys(data) + .filter(isTransactionEntry) + .map((key) => { + const transactionItem = data[key]; const from = data.personalDetailsList?.[transactionItem.accountID]; const to = data.personalDetailsList?.[transactionItem.managerID]; @@ -155,6 +197,30 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata }); } +function getReportActionsSections(data: OnyxTypes.SearchResults['data']): ReportActionListItemType[] { + const reportActionItems: ReportActionListItemType[] = []; + for (const key in data) { + if (isReportActionEntry(key)) { + const reportActions = data[key]; + for (const reportAction of Object.values(reportActions)) { + const from = data.personalDetailsList?.[reportAction.accountID]; + if (ReportActionsUtils.isDeletedAction(reportAction)) { + // eslint-disable-next-line no-continue + continue; + } + reportActionItems.push({ + ...reportAction, + from, + formattedFrom: from?.displayName ?? from?.login ?? '', + date: reportAction.created, + keyForList: reportAction.reportActionID, + }); + } + } + } + return reportActionItems; +} + function getIOUReportName(data: OnyxTypes.SearchResults['data'], reportItem: SearchReport) { const payerPersonalDetails = data.personalDetailsList?.[reportItem.managerID ?? 0]; const payerName = payerPersonalDetails?.displayName ?? payerPersonalDetails?.login ?? translateLocal('common.hidden'); @@ -183,7 +249,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx const reportIDToTransactions: Record<string, ReportListItemType> = {}; for (const key in data) { - if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { + if (isReportEntry(key)) { const reportItem = {...data[key]}; const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportItem.reportID}`; const transactions = reportIDToTransactions[reportKey]?.transactions ?? []; @@ -192,12 +258,12 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx reportIDToTransactions[reportKey] = { ...reportItem, keyForList: reportItem.reportID, - from: data.personalDetailsList?.[reportItem.accountID], - to: data.personalDetailsList?.[reportItem.managerID], + from: data.personalDetailsList?.[reportItem.accountID ?? -1], + to: data.personalDetailsList?.[reportItem.managerID ?? -1], transactions, reportName: isIOUReport ? getIOUReportName(data, reportItem) : reportItem.reportName, }; - } else if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { + } else if (isTransactionEntry(key)) { const transactionItem = {...data[key]}; const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${transactionItem.reportID}`; @@ -233,15 +299,24 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx return Object.values(reportIDToTransactions); } -function getListItem(status: SearchStatus): ListItemType<typeof status> { +function getListItem(type: SearchDataTypes, status: SearchStatus): ListItemType<typeof type, typeof status> { + if (type === CONST.SEARCH.DATA_TYPES.CHAT) { + return ChatListItem; + } return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? TransactionListItem : ReportListItem; } -function getSections(status: SearchStatus, data: OnyxTypes.SearchResults['data'], metadata: OnyxTypes.SearchResults['search']) { +function getSections(type: SearchDataTypes, status: SearchStatus, data: OnyxTypes.SearchResults['data'], metadata: OnyxTypes.SearchResults['search']) { + if (type === CONST.SEARCH.DATA_TYPES.CHAT) { + return getReportActionsSections(data); + } return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? getTransactionsSections(data, metadata) : getReportSections(data, metadata); } -function getSortedSections(status: SearchStatus, data: ListItemDataType<typeof status>, sortBy?: SearchColumnType, sortOrder?: SortOrder) { +function getSortedSections(type: SearchDataTypes, status: SearchStatus, data: ListItemDataType<typeof type, typeof status>, sortBy?: SearchColumnType, sortOrder?: SortOrder) { + if (type === CONST.SEARCH.DATA_TYPES.CHAT) { + return getSortedReportActionData(data as ReportActionListItemType[]); + } return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? getSortedTransactionData(data as TransactionListItemType[], sortBy, sortOrder) : getSortedReportData(data as ReportListItemType[]); } @@ -276,7 +351,24 @@ function getSortedTransactionData(data: TransactionListItemType[], sortBy?: Sear }); } +function getReportNewestTransactionDate(report: ReportListItemType) { + return report.transactions?.reduce((max, curr) => (curr.modifiedCreated ?? curr.created > max.created ? curr : max), report.transactions[0])?.created; +} + function getSortedReportData(data: ReportListItemType[]) { + return data.sort((a, b) => { + const aNewestTransaction = getReportNewestTransactionDate(a); + const bNewestTransaction = getReportNewestTransactionDate(b); + + if (!aNewestTransaction || !bNewestTransaction) { + return 0; + } + + return bNewestTransaction.toLowerCase().localeCompare(aNewestTransaction); + }); +} + +function getSortedReportActionData(data: ReportActionListItemType[]) { return data.sort((a, b) => { const aValue = a?.created; const bValue = b?.created; @@ -305,50 +397,6 @@ function getQueryHashFromString(query: SearchQueryString): number { return UserUtils.hashText(query, 2 ** 32); } -function buildSearchQueryJSON(query: SearchQueryString) { - try { - const result = searchParser.parse(query) as SearchQueryJSON; - - // Add the full input and hash to the results - result.inputQuery = query; - result.hash = getQueryHashFromString(query); - return result; - } catch (e) { - console.error(`Error when parsing SearchQuery: "${query}"`, e); - } -} - -function buildSearchQueryString(queryJSON?: SearchQueryJSON) { - const queryParts: string[] = []; - const defaultQueryJSON = buildSearchQueryJSON(''); - - for (const [, key] of Object.entries(CONST.SEARCH.SYNTAX_ROOT_KEYS)) { - const existingFieldValue = queryJSON?.[key]; - const queryFieldValue = existingFieldValue ?? defaultQueryJSON?.[key]; - - if (queryFieldValue) { - queryParts.push(`${key}:${queryFieldValue}`); - } - } - - if (!queryJSON) { - return queryParts.join(' '); - } - - const filters = getFilters(queryJSON); - - for (const [, filterKey] of Object.entries(CONST.SEARCH.SYNTAX_FILTER_KEYS)) { - const queryFilter = filters[filterKey]; - - if (queryFilter) { - const filterValueString = buildFilterString(filterKey, queryFilter); - queryParts.push(filterValueString); - } - } - - return queryParts.join(' '); -} - /** * Update string query with all the default params that are set by parser */ @@ -402,7 +450,8 @@ function buildAmountFilterQuery(filterValues: Partial<SearchAdvancedFiltersForm> } function sanitizeString(str: string) { - if (str.includes(' ') || str.includes(',')) { + const regexp = /[<>,:= ]/g; + if (regexp.test(str)) { return `"${str}"`; } return str; @@ -420,34 +469,73 @@ function getExpenseTypeTranslationKey(expenseType: ValueOf<typeof CONST.SEARCH.T } } -function getChatFiltersTranslationKey(has: ValueOf<typeof CONST.SEARCH.CHAT_TYPES>): TranslationPaths { - // eslint-disable-next-line default-case - switch (has) { - case CONST.SEARCH.CHAT_TYPES.LINK: - return 'search.filters.link'; - case CONST.SEARCH.CHAT_TYPES.ATTACHMENT: - return 'common.attachment'; +function buildSearchQueryJSON(query: SearchQueryString) { + try { + const result = searchParser.parse(query) as SearchQueryJSON; + const flatFilters = getFilters(result); + + // Add the full input and hash to the results + result.inputQuery = query; + result.hash = getQueryHashFromString(query); + result.flatFilters = flatFilters; + return result; + } catch (e) { + console.error(`Error when parsing SearchQuery: "${query}"`, e); } } +function buildSearchQueryString(queryJSON?: SearchQueryJSON) { + const queryParts: string[] = []; + const defaultQueryJSON = buildSearchQueryJSON(''); + + for (const [, key] of Object.entries(CONST.SEARCH.SYNTAX_ROOT_KEYS)) { + const existingFieldValue = queryJSON?.[key]; + const queryFieldValue = existingFieldValue ?? defaultQueryJSON?.[key]; + + if (queryFieldValue) { + queryParts.push(`${key}:${queryFieldValue}`); + } + } + + if (!queryJSON) { + return queryParts.join(' '); + } + + const filters = queryJSON.flatFilters; + + for (const [, filterKey] of Object.entries(CONST.SEARCH.SYNTAX_FILTER_KEYS)) { + const queryFilter = filters[filterKey]; + + if (queryFilter) { + const filterValueString = buildFilterString(filterKey, queryFilter); + queryParts.push(filterValueString); + } + } + + return queryParts.join(' '); +} + /** * Given object with chosen search filters builds correct query string from them */ -function buildQueryStringFromFilters(filterValues: Partial<SearchAdvancedFiltersForm>) { +function buildQueryStringFromFilterValues(filterValues: Partial<SearchAdvancedFiltersForm>) { const filtersString = Object.entries(filterValues).map(([filterKey, filterValue]) => { if ((filterKey === FILTER_KEYS.MERCHANT || filterKey === FILTER_KEYS.DESCRIPTION || filterKey === FILTER_KEYS.REPORT_ID) && filterValue) { - const keyInCorrectForm = (Object.keys(CONST.SEARCH.SYNTAX_FILTER_KEYS) as KeysOfFilterKeysObject[]).find((key) => CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); + const keyInCorrectForm = (Object.keys(CONST.SEARCH.SYNTAX_FILTER_KEYS) as FilterKeys[]).find((key) => CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); if (keyInCorrectForm) { return `${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${sanitizeString(filterValue as string)}`; } } if (filterKey === FILTER_KEYS.KEYWORD && filterValue) { - const keyInCorrectForm = (Object.keys(CONST.SEARCH.SYNTAX_FILTER_KEYS) as KeysOfFilterKeysObject[]).find((key) => CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); - if (keyInCorrectForm) { - return `${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${filterValue as string}`; - } + const value = (filterValue as string).split(' ').map(sanitizeString).join(' '); + return `${value}`; + } + if (filterKey === FILTER_KEYS.TYPE && filterValue) { + return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${sanitizeString(filterValue as string)}`; + } + if (filterKey === FILTER_KEYS.STATUS && filterValue) { + return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${sanitizeString(filterValue as string)}`; } - if ( (filterKey === FILTER_KEYS.CATEGORY || filterKey === FILTER_KEYS.CARD_ID || @@ -457,13 +545,12 @@ function buildQueryStringFromFilters(filterValues: Partial<SearchAdvancedFilters filterKey === FILTER_KEYS.CURRENCY || filterKey === FILTER_KEYS.FROM || filterKey === FILTER_KEYS.TO || - filterKey === FILTER_KEYS.IN || - filterKey === FILTER_KEYS.HAS) && + filterKey === FILTER_KEYS.IN) && Array.isArray(filterValue) && filterValue.length > 0 ) { - const filterValueArray = filterValues[filterKey] ?? []; - const keyInCorrectForm = (Object.keys(CONST.SEARCH.SYNTAX_FILTER_KEYS) as KeysOfFilterKeysObject[]).find((key) => CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); + const filterValueArray = [...new Set<string>(filterValues[filterKey] ?? [])]; + const keyInCorrectForm = (Object.keys(CONST.SEARCH.SYNTAX_FILTER_KEYS) as FilterKeys[]).find((key) => CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); if (keyInCorrectForm) { return `${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${filterValueArray.map(sanitizeString).join(',')}`; } @@ -481,6 +568,11 @@ function buildQueryStringFromFilters(filterValues: Partial<SearchAdvancedFilters return filtersString.filter(Boolean).join(' '); } +/** + * + * @private + * traverses the AST and returns filters as a QueryFilters object + */ function getFilters(queryJSON: SearchQueryJSON) { const filters = {} as QueryFilters; const filterKeys = Object.values(CONST.SEARCH.SYNTAX_FILTER_KEYS); @@ -494,7 +586,7 @@ function getFilters(queryJSON: SearchQueryJSON) { traverse(node.left); } - if (typeof node?.right === 'object' && node.right) { + if (typeof node?.right === 'object' && node.right && !Array.isArray(node.right)) { traverse(node.right); } @@ -509,10 +601,19 @@ function getFilters(queryJSON: SearchQueryJSON) { // the "?? []" is added only for typescript because otherwise TS throws an error, in newer TS versions this should be fixed const filterArray = filters[nodeKey] ?? []; - filterArray.push({ - operator: node.operator, - value: node.right as string | number, - }); + if (!Array.isArray(node.right)) { + filterArray.push({ + operator: node.operator, + value: node.right as string | number, + }); + } else { + node.right.forEach((element) => { + filterArray.push({ + operator: node.operator, + value: element as string | number, + }); + }); + } } if (queryJSON.filters) { @@ -522,6 +623,57 @@ function getFilters(queryJSON: SearchQueryJSON) { return filters; } +/** + * returns the values of the filters in a format that can be used in the SearchAdvancedFiltersForm as initial form values + */ +function getFiltersFormValues(queryJSON: SearchQueryJSON) { + const filters = getFilters(queryJSON); + const filterKeys = Object.keys(filters); + const filtersForm = {} as Partial<SearchAdvancedFiltersForm>; + for (const filterKey of filterKeys) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) { + filtersForm[filterKey] = filters[filterKey]?.[0]?.value.toString(); + } + if ( + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN + ) { + filtersForm[filterKey] = filters[filterKey]?.map((filter) => filter.value.toString()); + } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) { + filtersForm[filterKey] = filters[filterKey] + ?.map((filter) => filter.value.toString()) + .map((filter) => { + if (filter.includes(' ')) { + return `"${filter}"`; + } + return filter; + }) + .join(' '); + } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) { + filtersForm[FILTER_KEYS.DATE_BEFORE] = filters[filterKey]?.find((filter) => filter.operator === 'lt')?.value.toString(); + filtersForm[FILTER_KEYS.DATE_AFTER] = filters[filterKey]?.find((filter) => filter.operator === 'gt')?.value.toString(); + } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + filtersForm[FILTER_KEYS.LESS_THAN] = filters[filterKey]?.find((filter) => filter.operator === 'lt')?.value.toString(); + filtersForm[FILTER_KEYS.GREATER_THAN] = filters[filterKey]?.find((filter) => filter.operator === 'gt')?.value.toString(); + } + } + + filtersForm[FILTER_KEYS.TYPE] = queryJSON.type; + filtersForm[FILTER_KEYS.STATUS] = queryJSON.status; + + return filtersForm; +} + /** * Given a SearchQueryJSON this function will try to find the value of policyID filter saved in query * and return just the first policyID value from the filter. @@ -542,29 +694,64 @@ function getPolicyIDFromSearchQuery(queryJSON: SearchQueryJSON) { return policyID; } -function buildFilterString(filterName: string, queryFilters: QueryFilter[]) { +function getDisplayValue(filterName: string, filter: string, personalDetails: OnyxTypes.PersonalDetailsList, cardList: OnyxTypes.CardList, reports: OnyxCollection<OnyxTypes.Report>) { + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { + return PersonalDetailsUtils.createDisplayName(personalDetails[filter]?.login ?? '', personalDetails[filter]); + } + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { + return cardList[filter].bank; + } + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN) { + return ReportUtils.getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${filter}`]); + } + return filter; +} + +function buildFilterString(filterName: string, queryFilters: QueryFilter[], delimiter = ',') { let filterValueString = ''; queryFilters.forEach((queryFilter, index) => { // If the previous queryFilter has the same operator (this rule applies only to eq and neq operators) then append the current value if ((queryFilter.operator === 'eq' && queryFilters[index - 1]?.operator === 'eq') || (queryFilter.operator === 'neq' && queryFilters[index - 1]?.operator === 'neq')) { - filterValueString += ` ${sanitizeString(queryFilter.value.toString())}`; + filterValueString += `${delimiter}${sanitizeString(queryFilter.value.toString())}`; } else { - filterValueString += ` ${filterName}${operatorToSignMap[queryFilter.operator]}${queryFilter.value}`; + filterValueString += ` ${filterName}${operatorToSignMap[queryFilter.operator]}${sanitizeString(queryFilter.value.toString())}`; } }); return filterValueString; } -function getSearchHeaderTitle(queryJSON: SearchQueryJSON) { +function getSearchHeaderTitle( + queryJSON: SearchQueryJSON, + PersonalDetails: OnyxTypes.PersonalDetailsList, + cardList: OnyxTypes.CardList, + reports: OnyxCollection<OnyxTypes.Report>, + TaxRates: Record<string, string[]>, +) { const {type, status} = queryJSON; - const filters = getFilters(queryJSON) ?? {}; + const filters = queryJSON.flatFilters ?? {}; let title = `type:${type} status:${status}`; Object.keys(filters).forEach((key) => { const queryFilter = filters[key as ValueOf<typeof CONST.SEARCH.SYNTAX_FILTER_KEYS>] ?? []; - title += buildFilterString(key, queryFilter); + let displayQueryFilters: QueryFilter[] = []; + if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { + const taxRateIDs = queryFilter.map((filter) => filter.value.toString()); + const taxRateNames = Object.entries(TaxRates) + .filter(([, taxRateKeys]) => taxRateKeys.some((taxID) => taxRateIDs.includes(taxID))) + .map(([taxRate]) => taxRate); + displayQueryFilters = taxRateNames.map((taxRate) => ({ + operator: queryFilter[0].operator, + value: taxRate, + })); + } else { + displayQueryFilters = queryFilter.map((filter) => ({ + operator: filter.operator, + value: getDisplayValue(key, filter.value.toString(), PersonalDetails, cardList, reports), + })); + } + title += buildFilterString(key, displayQueryFilters, ' '); }); return title; @@ -585,11 +772,11 @@ function isCannedSearchQuery(queryJSON: SearchQueryJSON) { } export { - buildQueryStringFromFilters, + buildQueryStringFromFilterValues, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchParams, - getFilters, + getFiltersFormValues, getPolicyIDFromSearchQuery, getListItem, getSearchHeaderTitle, @@ -599,10 +786,10 @@ export { isReportListItemType, isSearchResultsEmpty, isTransactionListItemType, + isReportActionListItemType, normalizeQuery, shouldShowYear, buildCannedSearchQuery, isCannedSearchQuery, getExpenseTypeTranslationKey, - getChatFiltersTranslationKey, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 0e8447635098..4bd7e2714e25 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -103,7 +103,7 @@ function getOrderedReportIDs( const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {}; const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); const doesReportHaveViolations = OptionsListUtils.shouldShowViolations(report, transactionViolations); - const isHidden = report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const isHidden = ReportUtils.getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const isFocused = report.reportID === currentReportId; const allReportErrors = OptionsListUtils.getAllReportErrors(report, reportActions) ?? {}; const transactionReportActions = ReportActionsUtils.getAllReportActions(report.reportID); @@ -129,10 +129,10 @@ function getOrderedReportIDs( return; } const isSystemChat = ReportUtils.isSystemChat(report); - const isSubmittedExpenseReportManagerWithoutParentAccess = ReportUtils.isSubmittedExpenseReportManagerWithoutParentAccess(report); + const isExpenseReportManagerWithoutParentAccess = ReportUtils.isExpenseReportManagerWithoutParentAccess(report); const shouldOverrideHidden = // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - hasValidDraftComment(report.reportID) || hasErrorsOtherThanFailedReceipt || isFocused || isSystemChat || report.isPinned || isSubmittedExpenseReportManagerWithoutParentAccess; + hasValidDraftComment(report.reportID) || hasErrorsOtherThanFailedReceipt || isFocused || isSystemChat || report.isPinned || isExpenseReportManagerWithoutParentAccess; if (isHidden && !shouldOverrideHidden) { return; } @@ -240,6 +240,7 @@ function getOptionData({ policy, parentReportAction, hasViolations, + lastMessageTextFromReport: lastMessageTextFromReportProp, transactionViolations, invoiceReceiverPolicy, }: { @@ -250,6 +251,7 @@ function getOptionData({ policy: OnyxEntry<Policy> | undefined; parentReportAction: OnyxEntry<ReportAction> | undefined; hasViolations: boolean; + lastMessageTextFromReport?: string; invoiceReceiverPolicy?: OnyxEntry<Policy>; transactionViolations?: OnyxCollection<TransactionViolation[]>; }): ReportUtils.OptionData | undefined { @@ -296,7 +298,7 @@ function getOptionData({ const participantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report); const visibleParticipantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report, true); - const participantPersonalDetailList = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails)) as PersonalDetails[]; + const participantPersonalDetailList = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails)); const personalDetail = participantPersonalDetailList[0] ?? {}; const hasErrors = Object.keys(result.allReportErrors ?? {}).length !== 0; @@ -343,7 +345,7 @@ function getOptionData({ result.hasOutstandingChildRequest = report.hasOutstandingChildRequest; result.parentReportID = report.parentReportID ?? '-1'; result.isWaitingOnBankAccount = report.isWaitingOnBankAccount; - result.notificationPreference = report.notificationPreference; + result.notificationPreference = ReportUtils.getReportNotificationPreference(report); result.isAllowedToComment = ReportUtils.canUserPerformWriteAction(report); result.chatType = report.chatType; result.isDeletedParentAction = report.isDeletedParentAction; @@ -384,7 +386,11 @@ function getOptionData({ } const lastActorDisplayName = OptionsListUtils.getLastActorDisplayName(lastActorDetails, hasMultipleParticipants); - const lastMessageTextFromReport = OptionsListUtils.getLastMessageTextForReport(report, lastActorDetails, policy); + + let lastMessageTextFromReport = lastMessageTextFromReportProp; + if (!lastMessageTextFromReport) { + lastMessageTextFromReport = OptionsListUtils.getLastMessageTextForReport(report, lastActorDetails, policy); + } // We need to remove sms domain in case the last message text has a phone number mention with sms domain. let lastMessageText = Str.removeSMSDomain(lastMessageTextFromReport); diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 339f57c8e04a..083a8d238156 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -184,6 +184,11 @@ function hasReceipt(transaction: OnyxInputOrEntry<Transaction> | undefined): boo return !!transaction?.receipt?.state || hasEReceipt(transaction); } +/** Check if the receipt has the source file */ +function hasReceiptSource(transaction: OnyxInputOrEntry<Transaction>): boolean { + return !!transaction?.receipt?.source; +} + function isMerchantMissing(transaction: OnyxEntry<Transaction>) { if (transaction?.modifiedMerchant && transaction.modifiedMerchant !== '') { return transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; @@ -1136,6 +1141,7 @@ export { isPayAtEndExpense, removeSettledAndApprovedTransactions, getCardName, + hasReceiptSource, }; export type {TransactionChanges}; diff --git a/src/libs/UnreadIndicatorUpdater/index.ts b/src/libs/UnreadIndicatorUpdater/index.ts index d6a65ee85aac..f4b8e3281308 100644 --- a/src/libs/UnreadIndicatorUpdater/index.ts +++ b/src/libs/UnreadIndicatorUpdater/index.ts @@ -9,8 +9,9 @@ import type {Report} from '@src/types/onyx'; import updateUnread from './updateUnread'; function getUnreadReportsForUnreadIndicator(reports: OnyxCollection<Report>, currentReportID: string) { - return Object.values(reports ?? {}).filter( - (report) => + return Object.values(reports ?? {}).filter((report) => { + const notificationPreference = ReportUtils.getReportNotificationPreference(report); + return ( ReportUtils.isUnread(report) && ReportUtils.shouldReportBeInOptionList({ report, @@ -29,9 +30,10 @@ function getUnreadReportsForUnreadIndicator(reports: OnyxCollection<Report>, cur * Furthermore, muted reports may or may not appear in the LHN depending on priority mode, * but they should not be considered in the unread indicator count. */ - report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN && - report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE, - ); + notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN && + notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE + ); + }); } const memoizedGetUnreadReportsForUnreadIndicator = memoize(getUnreadReportsForUnreadIndicator, {maxArgs: 1}); diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 4682654d3583..7d936bff0b3b 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -200,7 +200,10 @@ type ConvertApprovalWorkflowToPolicyEmployeesParams = { type: ValueOf<typeof CONST.APPROVAL_WORKFLOW.TYPE>; }; -/** Convert an approval workflow to a list of policy employees */ +/** + * This function converts an approval workflow into a list of policy employees. + * An optimized list is created that contains only the updated employees to maintain minimal data changes. + */ function convertApprovalWorkflowToPolicyEmployees({ approvalWorkflow, previousEmployeeList, @@ -221,6 +224,8 @@ function convertApprovalWorkflowToPolicyEmployees({ const nextApprover = approvalWorkflow.approvers.at(index + 1); const forwardsTo = type === CONST.APPROVAL_WORKFLOW.TYPE.REMOVE ? '' : nextApprover?.email ?? ''; + // For every approver, we check if the forwardsTo field has changed. + // If it has, we update the employee list with the new forwardsTo value. if (previousEmployeeList[approver.email]?.forwardsTo === forwardsTo) { return; } @@ -235,6 +240,8 @@ function convertApprovalWorkflowToPolicyEmployees({ approvalWorkflow.members.forEach(({email}) => { const submitsTo = type === CONST.APPROVAL_WORKFLOW.TYPE.REMOVE ? '' : firstApprover.email ?? ''; + // For every member, we check if the submitsTo field has changed. + // If it has, we update the employee list with the new submitsTo value. if (previousEmployeeList[email]?.submitsTo === submitsTo) { return; } @@ -246,6 +253,8 @@ function convertApprovalWorkflowToPolicyEmployees({ }; }); + // For each member to remove, we update the employee list with submitsTo set to '' + // which will set the submitsTo field to the default approver email on backend. membersToRemove?.forEach(({email}) => { updatedEmployeeList[email] = { ...(updatedEmployeeList[email] ? updatedEmployeeList[email] : {email}), @@ -254,6 +263,8 @@ function convertApprovalWorkflowToPolicyEmployees({ }; }); + // For each approver to remove, we update the employee list with forwardsTo set to '' + // which will reset the forwardsTo on the backend. approversToRemove?.forEach(({email}) => { updatedEmployeeList[email] = { ...(updatedEmployeeList[email] ? updatedEmployeeList[email] : {email}), diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index a09baf7109b7..15d1eadb662d 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -194,14 +194,20 @@ function clearCardListErrors(cardID: number) { * * @returns promise with card details object */ -function revealVirtualCardDetails(cardID: number): Promise<ExpensifyCardDetails> { +function revealVirtualCardDetails(cardID: number, validateCode: string): Promise<ExpensifyCardDetails> { return new Promise((resolve, reject) => { - const parameters: RevealExpensifyCardDetailsParams = {cardID}; + const parameters: RevealExpensifyCardDetailsParams = {cardID, validateCode}; // eslint-disable-next-line rulesdir/no-api-side-effects-method API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REVEAL_EXPENSIFY_CARD_DETAILS, parameters) .then((response) => { if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + if (response?.message === 'Incorrect or invalid magic code. Please try again.') { + // eslint-disable-next-line prefer-promise-reject-errors + reject('validateCodeForm.error.incorrectMagicCode'); + return; + } + // eslint-disable-next-line prefer-promise-reject-errors reject('cardPage.cardDetailsLoadingFailure'); return; @@ -608,8 +614,10 @@ function issueExpensifyCard(policyID: string, feedCountry: string, data?: IssueN return; } + const domainAccountID = PolicyUtils.getWorkspaceAccountID(policyID); + // eslint-disable-next-line rulesdir/no-multiple-api-calls - API.write(WRITE_COMMANDS.CREATE_ADMIN_ISSUED_VIRTUAL_CARD, parameters); + API.write(WRITE_COMMANDS.CREATE_ADMIN_ISSUED_VIRTUAL_CARD, {...parameters, domainAccountID}); } function openCardDetailsPage(cardID: number) { diff --git a/src/libs/actions/CompanyCards.ts b/src/libs/actions/CompanyCards.ts index 1e58ea3b6306..3c3e239d90d7 100644 --- a/src/libs/actions/CompanyCards.ts +++ b/src/libs/actions/CompanyCards.ts @@ -1,6 +1,18 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; import type {AssignCard} from '@src/types/onyx/AssignCard'; +import type {AddNewCardFeedData, AddNewCardFeedStep} from '@src/types/onyx/CardFeeds'; + +type AddNewCompanyCardFlowData = { + /** Step to be set in Onyx */ + step?: AddNewCardFeedStep; + + /** Whether the user is editing step */ + isEditing?: boolean; + + /** Data required to be sent to issue a new card */ + data?: Partial<AddNewCardFeedData>; +}; function setAssignCardStepAndData({data, isEditing, currentStep}: Partial<AssignCard>) { Onyx.merge(ONYXKEYS.ASSIGN_CARD, {data, isEditing, currentStep}); @@ -10,4 +22,15 @@ function clearAssignCardStepAndData() { Onyx.set(ONYXKEYS.ASSIGN_CARD, {}); } -export {setAssignCardStepAndData, clearAssignCardStepAndData}; +function setAddNewCompanyCardStepAndData({data, isEditing, step}: AddNewCompanyCardFlowData) { + Onyx.merge(ONYXKEYS.ADD_NEW_COMPANY_CARD, {data, isEditing, currentStep: step}); +} + +function clearAddNewCardFlow() { + Onyx.set(ONYXKEYS.ADD_NEW_COMPANY_CARD, { + currentStep: null, + data: {}, + }); +} + +export {setAddNewCompanyCardStepAndData, clearAddNewCardFlow, setAssignCardStepAndData, clearAssignCardStepAndData}; diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 4797506d1a3c..50d2ee7fc194 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -1,12 +1,15 @@ import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; -import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import type {AddDelegateParams} from '@libs/API/parameters'; +import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; import * as NetworkStore from '@libs/Network/NetworkStore'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {DelegatedAccess} from '@src/types/onyx/Account'; +import type {Delegate, DelegatedAccess, DelegateRole} from '@src/types/onyx/Account'; import {confirmReadyToOpenApp, openApp} from './App'; import updateSessionAuthTokens from './Session/updateSessionAuthTokens'; @@ -31,7 +34,7 @@ function connect(email: string) { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { - delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, error: undefined} : delegator)), + delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, errorFields: {connect: null}} : delegator)), }, }, }, @@ -43,7 +46,7 @@ function connect(email: string) { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { - delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, error: undefined} : delegator)), + delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, errorFields: undefined} : delegator)), }, }, }, @@ -55,7 +58,9 @@ function connect(email: string) { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { - delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, error: 'delegate.genericError'} : delegator)), + delegators: delegatedAccess.delegators.map((delegator) => + delegator.email === email ? {...delegator, errorFields: {connect: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError')}} : delegator, + ), }, }, }, @@ -94,7 +99,7 @@ function disconnect() { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { - error: null, + errorFields: {connect: null}, }, }, }, @@ -106,7 +111,7 @@ function disconnect() { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { - error: null, + errorFields: undefined, }, }, }, @@ -118,7 +123,7 @@ function disconnect() { key: ONYXKEYS.ACCOUNT, value: { delegatedAccess: { - error: 'delegate.genericError', + errorFields: {connect: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError')}, }, }, }, @@ -153,7 +158,171 @@ function clearDelegatorErrors() { if (!delegatedAccess?.delegators) { return; } - Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess: {delegators: delegatedAccess.delegators.map((delegator) => ({...delegator, error: undefined}))}}); + Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess: {delegators: delegatedAccess.delegators.map((delegator) => ({...delegator, errorFields: undefined}))}}); } -export {connect, clearDelegatorErrors, disconnect}; +function requestValidationCode() { + API.write(WRITE_COMMANDS.RESEND_VALIDATE_CODE, null); +} + +function addDelegate(email: string, role: DelegateRole, validateCode: string) { + const existingDelegate = delegatedAccess?.delegates?.find((delegate) => delegate.email === email); + + const optimisticDelegateData = (): Delegate[] => { + if (existingDelegate) { + return ( + delegatedAccess.delegates?.map((delegate) => + delegate.email !== email + ? delegate + : { + ...delegate, + isLoading: true, + errorFields: {addDelegate: null}, + pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ) ?? [] + ); + } + + return [ + ...(delegatedAccess.delegates ?? []), + { + email, + role, + isLoading: true, + errorFields: {addDelegate: null}, + pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ]; + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegates: optimisticDelegateData(), + }, + }, + }, + ]; + + const successDelegateData = (): Delegate[] => { + if (existingDelegate) { + return ( + delegatedAccess.delegates?.map((delegate) => + delegate.email !== email + ? delegate + : { + ...delegate, + isLoading: false, + errorFields: {addDelegate: null}, + pendingAction: null, + pendingFields: {email: null, role: null}, + optimisticAccountID: undefined, + }, + ) ?? [] + ); + } + + return [ + ...(delegatedAccess.delegates ?? []), + { + email, + role, + errorFields: {addDelegate: null}, + isLoading: false, + pendingAction: null, + pendingFields: {email: null, role: null}, + optimisticAccountID: undefined, + }, + ]; + }; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegates: successDelegateData(), + }, + }, + }, + ]; + + const failureDelegateData = (): Delegate[] => { + if (existingDelegate) { + return ( + delegatedAccess.delegates?.map((delegate) => + delegate.email !== email + ? delegate + : { + ...delegate, + isLoading: false, + errorFields: {addDelegate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('contacts.genericFailureMessages.validateSecondaryLogin')}, + }, + ) ?? [] + ); + } + + return [ + ...(delegatedAccess.delegates ?? []), + { + email, + role, + errorFields: { + addDelegate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('contacts.genericFailureMessages.validateSecondaryLogin'), + }, + isLoading: false, + pendingFields: {email: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ]; + }; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegates: failureDelegateData(), + }, + }, + }, + ]; + + const parameters: AddDelegateParams = {delegate: email, validateCode, role}; + + API.write(WRITE_COMMANDS.ADD_DELEGATE, parameters, {optimisticData, successData, failureData}); +} + +function clearAddDelegateErrors(email: string, fieldName: string) { + if (!delegatedAccess?.delegates) { + return; + } + + Onyx.merge(ONYXKEYS.ACCOUNT, { + delegatedAccess: { + delegates: delegatedAccess.delegates.map((delegate) => (delegate.email !== email ? delegate : {...delegate, errorFields: {...delegate.errorFields, [fieldName]: null}})), + }, + }); +} + +function removePendingDelegate(email: string) { + if (!delegatedAccess?.delegates) { + return; + } + + Onyx.merge(ONYXKEYS.ACCOUNT, { + delegatedAccess: { + delegates: delegatedAccess.delegates.filter((delegate) => delegate.email !== email), + }, + }); +} + +export {connect, disconnect, clearDelegatorErrors, addDelegate, requestValidationCode, clearAddDelegateErrors, removePendingDelegate}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 29d481737790..5d2ab768ecd8 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -41,12 +41,14 @@ import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralP import Navigation from '@libs/Navigation/Navigation'; import * as NextStepUtils from '@libs/NextStepUtils'; import {rand64} from '@libs/NumberUtils'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportConnection from '@libs/ReportConnection'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as SessionUtils from '@libs/SessionUtils'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import {getCurrency, getTransaction} from '@libs/TransactionUtils'; @@ -139,7 +141,7 @@ type UpdateMoneyRequestData = { }; type PayMoneyRequestData = { - params: PayMoneyRequestParams; + params: PayMoneyRequestParams & Partial<PayInvoiceParams>; optimisticData: OnyxUpdate[]; successData: OnyxUpdate[]; failureData: OnyxUpdate[]; @@ -272,6 +274,18 @@ Onyx.connect({ callback: (value) => (activePolicyID = value), }); +let introSelected: OnyxEntry<OnyxTypes.IntroSelected>; +Onyx.connect({ + key: ONYXKEYS.NVP_INTRO_SELECTED, + callback: (value) => (introSelected = value), +}); + +let personalDetailsList: OnyxEntry<OnyxTypes.PersonalDetailsList>; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (value) => (personalDetailsList = value), +}); + /** * Find the report preview action from given chat report and iou report */ @@ -884,13 +898,6 @@ function buildOnyxDataForMoneyRequest( }); } - // Show field violations only for control policies - if (PolicyUtils.isControlPolicy(policy)) { - const {optimisticData: fieldViolationsOptimisticData, failureData: fieldViolationsFailureData} = getFieldViolationsOnyxData(iouReport); - optimisticData.push(...fieldViolationsOptimisticData); - failureData.push(...fieldViolationsFailureData); - } - return [optimisticData, successData, failureData]; } @@ -3666,6 +3673,7 @@ function trackExpense( actionableWhisperReportActionID?: string, linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction, linkedTrackedExpenseReportID?: string, + customUnitRateID?: string, ) { const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReportOrDraftReport(report.chatReportID) : report; @@ -3805,6 +3813,7 @@ function trackExpense( transactionThreadReportID: transactionThreadReportID ?? '-1', createdReportActionIDForThread: createdReportActionIDForThread ?? '-1', waypoints: validWaypoints ? JSON.stringify(validWaypoints) : undefined, + customUnitRateID, }; if (actionableWhisperReportActionIDParam) { parameters.actionableWhisperReportActionID = actionableWhisperReportActionIDParam; @@ -3815,7 +3824,7 @@ function trackExpense( Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : activeReportID); if (action === CONST.IOU.ACTION.SHARE) { - Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(activeReportID ?? '-1', CONST.IOU.SHARE.ROLE.ACCOUNTANT)); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(activeReportID ?? '-1', CONST.IOU.SHARE.ROLE.ACCOUNTANT))); } Report.notifyNewAction(activeReportID ?? '', payeeAccountID); @@ -3851,7 +3860,7 @@ function getOrCreateOptimisticSplitChatReport(existingSplitChatReportID: string, undefined, undefined, undefined, - CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, ); return {existingSplitChatReport: null, splitChatReport}; } @@ -3951,11 +3960,6 @@ function createSplitsAndOnyxData( splitChatReport.lastActorAccountID = currentUserAccountID; splitChatReport.lastVisibleActionCreated = splitIOUReportAction.created; - let splitChatReportNotificationPreference = splitChatReport.notificationPreference; - if (splitChatReportNotificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { - splitChatReportNotificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; - } - // If we have an existing splitChatReport (group chat or workspace) use it's pending fields, otherwise indicate that we are adding a chat if (!existingSplitChatReport) { splitChatReport.pendingFields = { @@ -3969,10 +3973,7 @@ function createSplitsAndOnyxData( // and we need the data to be available when we navigate to the chat page onyxMethod: existingSplitChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, - value: { - ...splitChatReport, - notificationPreference: splitChatReportNotificationPreference, - }, + value: splitChatReport, }, { onyxMethod: Onyx.METHOD.SET, @@ -6556,7 +6557,54 @@ function getPayMoneyRequestParams( payAsBusiness?: boolean, ): PayMoneyRequestData { const isInvoiceReport = ReportUtils.isInvoiceReport(iouReport); + const activePolicy = PolicyUtils.getPolicy(activePolicyID); + let payerPolicyID = activePolicyID; let chatReport = initialChatReport; + let policyParams = {}; + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + const shouldCreatePolicy = !activePolicy || !PolicyUtils.isPolicyAdmin(activePolicy) || !PolicyUtils.isPaidGroupPolicy(activePolicy); + + if (ReportUtils.isIndividualInvoiceRoom(chatReport) && payAsBusiness && shouldCreatePolicy) { + payerPolicyID = Policy.generatePolicyID(); + const { + optimisticData: policyOptimisticData, + failureData: policyFailureData, + successData: policySuccessData, + params, + } = Policy.buildPolicyData(currentUserEmail, true, undefined, payerPolicyID); + const { + announceChatReportID, + announceCreatedReportActionID, + adminsChatReportID, + adminsCreatedReportActionID, + expenseChatReportID, + expenseCreatedReportActionID, + customUnitRateID, + customUnitID, + ownerEmail, + policyName, + } = params; + + policyParams = { + policyID: payerPolicyID, + announceChatReportID, + announceCreatedReportActionID, + adminsChatReportID, + adminsCreatedReportActionID, + expenseChatReportID, + expenseCreatedReportActionID, + customUnitRateID, + customUnitID, + ownerEmail, + policyName, + }; + + optimisticData.push(...policyOptimisticData, {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, value: payerPolicyID}); + successData.push(...policySuccessData); + failureData.push(...policyFailureData, {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, value: activePolicyID ?? null}); + } if (ReportUtils.isIndividualInvoiceRoom(chatReport) && payAsBusiness && activePolicyID) { const existingB2BInvoiceRoom = ReportUtils.getInvoiceChatByParticipants(chatReport.policyID ?? '', activePolicyID); @@ -6605,14 +6653,14 @@ function getPayMoneyRequestParams( lastMessageText: ReportActionsUtils.getReportActionText(optimisticIOUReportAction), lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticIOUReportAction), }; - if (ReportUtils.isIndividualInvoiceRoom(chatReport) && payAsBusiness && activePolicyID) { + if (ReportUtils.isIndividualInvoiceRoom(chatReport) && payAsBusiness && payerPolicyID) { optimisticChatReport.invoiceReceiver = { type: CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, - policyID: activePolicyID, + policyID: payerPolicyID, }; } - const optimisticData: OnyxUpdate[] = [ + optimisticData.push( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, @@ -6654,23 +6702,21 @@ function getPayMoneyRequestParams( key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`, value: optimisticNextStep, }, - ]; + ); - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, - value: { - pendingFields: { - preview: null, - reimbursed: null, - partial: null, - }, + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + pendingFields: { + preview: null, + reimbursed: null, + partial: null, }, }, - ]; + }); - const failureData: OnyxUpdate[] = [ + failureData.push( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, @@ -6697,7 +6743,7 @@ function getPayMoneyRequestParams( key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`, value: currentNextStep, }, - ]; + ); // In case the report preview action is loaded locally, let's update it. if (optimisticReportPreviewAction) { @@ -6768,6 +6814,7 @@ function getPayMoneyRequestParams( optimisticHoldReportID, optimisticHoldActionID, optimisticHoldReportExpenseActionIDs, + ...policyParams, }, optimisticData, successData, @@ -7389,12 +7436,52 @@ function cancelPayment(expenseReport: OnyxEntry<OnyxTypes.Report>, chatReport: O ); } +/** + * Completes onboarding for invite link flow based on the selected payment option + * + * @param paymentSelected based on which we choose the onboarding choice and concierge message + */ +function completePaymentOnboarding(paymentSelected: ValueOf<typeof CONST.PAYMENT_SELECTED>) { + const isInviteOnboardingComplete = introSelected?.isInviteOnboardingComplete ?? false; + + if (isInviteOnboardingComplete || !introSelected?.choice) { + return; + } + + const session = SessionUtils.getSession(); + + const personalDetailsListValues = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(session?.accountID ? [session.accountID] : [], personalDetailsList)); + const personalDetails = personalDetailsListValues[0] ?? {}; + + let onboardingPurpose = introSelected.choice; + if (introSelected.inviteType === CONST.ONBOARDING_INVITE_TYPES.IOU && paymentSelected === CONST.IOU.PAYMENT_SELECTED.BBA) { + onboardingPurpose = CONST.ONBOARDING_CHOICES.MANAGE_TEAM; + } + + if (introSelected.inviteType === CONST.ONBOARDING_INVITE_TYPES.INVOICE && paymentSelected !== CONST.IOU.PAYMENT_SELECTED.BBA) { + onboardingPurpose = CONST.ONBOARDING_CHOICES.CHAT_SPLIT; + } + + Report.completeOnboarding( + onboardingPurpose, + CONST.ONBOARDING_MESSAGES[onboardingPurpose], + { + firstName: personalDetails.firstName ?? '', + lastName: personalDetails.lastName ?? '', + }, + paymentSelected, + ); +} + function payMoneyRequest(paymentType: PaymentMethodType, chatReport: OnyxTypes.Report, iouReport: OnyxTypes.Report, full = true) { if (chatReport.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(chatReport.policyID)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(chatReport.policyID)); return; } + const paymentSelected = paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? CONST.IOU.PAYMENT_SELECTED.BBA : CONST.IOU.PAYMENT_SELECTED.PBA; + completePaymentOnboarding(paymentSelected); + const recipient = {accountID: iouReport.ownerAccountID}; const {params, optimisticData, successData, failureData} = getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentType, full); @@ -7411,16 +7498,49 @@ function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes. optimisticData, successData, failureData, - params: {reportActionID}, + params: { + reportActionID, + policyID, + announceChatReportID, + announceCreatedReportActionID, + adminsChatReportID, + adminsCreatedReportActionID, + expenseChatReportID, + expenseCreatedReportActionID, + customUnitRateID, + customUnitID, + ownerEmail, + policyName, + }, } = getPayMoneyRequestParams(chatReport, invoiceReport, recipient, paymentMethodType, true, payAsBusiness); - const params: PayInvoiceParams = { + const paymentSelected = paymentMethodType === CONST.IOU.PAYMENT_TYPE.VBBA ? CONST.IOU.PAYMENT_SELECTED.BBA : CONST.IOU.PAYMENT_SELECTED.PBA; + completePaymentOnboarding(paymentSelected); + + let params: PayInvoiceParams = { reportID: invoiceReport.reportID, reportActionID, paymentMethodType, payAsBusiness, }; + if (policyID) { + params = { + ...params, + policyID, + announceChatReportID, + announceCreatedReportActionID, + adminsChatReportID, + adminsCreatedReportActionID, + expenseChatReportID, + expenseCreatedReportActionID, + customUnitRateID, + customUnitID, + ownerEmail, + policyName, + }; + } + API.write(WRITE_COMMANDS.PAY_INVOICE, params, {optimisticData, successData, failureData}); } @@ -8009,6 +8129,7 @@ export { getIOURequestPolicyID, initMoneyRequest, navigateToStartStepIfScanFileCannotBeRead, + completePaymentOnboarding, payInvoice, payMoneyRequest, putOnHold, @@ -8059,5 +8180,6 @@ export { updateMoneyRequestTaxAmount, updateMoneyRequestTaxRate, mergeDuplicates, + prepareToCleanUpMoneyRequest, }; export type {GPSPoint as GpsPoint, IOURequestType}; diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 4be9c8d37d63..501b7cbbe1e5 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -1,20 +1,38 @@ +import lodashCloneDeep from 'lodash/cloneDeep'; import lodashUnion from 'lodash/union'; import type {NullishDeep, OnyxCollection, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; -import type {EnablePolicyCategoriesParams, OpenPolicyCategoriesPageParams, SetPolicyDistanceRatesDefaultCategoryParams, UpdatePolicyCategoryGLCodeParams} from '@libs/API/parameters'; +import type { + EnablePolicyCategoriesParams, + OpenPolicyCategoriesPageParams, + RemovePolicyCategoryReceiptsRequiredParams, + SetPolicyCategoryApproverParams, + SetPolicyCategoryDescriptionRequiredParams, + SetPolicyCategoryMaxAmountParams, + SetPolicyCategoryReceiptsRequiredParams, + SetPolicyCategoryTaxParams, + SetPolicyDistanceRatesDefaultCategoryParams, + SetWorkspaceCategoryDescriptionHintParams, + UpdatePolicyCategoryGLCodeParams, +} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ApiUtils from '@libs/ApiUtils'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import fileDownload from '@libs/fileDownload'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; +import enhanceParameters from '@libs/Network/enhanceParameters'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {navigateWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyCategory, RecentlyUsedCategories, Report} from '@src/types/onyx'; -import type {CustomUnit} from '@src/types/onyx/Policy'; +import type {ApprovalRule, CustomUnit, ExpenseRule} from '@src/types/onyx/Policy'; +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; const allPolicies: OnyxCollection<Policy> = {}; @@ -287,6 +305,196 @@ function setWorkspaceCategoryEnabled(policyID: string, categoriesToUpdate: Recor API.write(WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED, parameters, onyxData); } +function setPolicyCategoryDescriptionRequired(policyID: string, categoryName: string, areCommentsRequired: boolean) { + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; + const originalAreCommentsRequired = policyCategoryToUpdate?.areCommentsRequired; + const originalCommentHint = policyCategoryToUpdate?.commentHint; + + // When areCommentsRequired is set to false, commentHint has to be reset + const updatedCommentHint = areCommentsRequired ? allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.commentHint : ''; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + areCommentsRequired: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + areCommentsRequired, + commentHint: updatedCommentHint, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + areCommentsRequired: null, + }, + areCommentsRequired, + commentHint: updatedCommentHint, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + areCommentsRequired: null, + }, + areCommentsRequired: originalAreCommentsRequired, + commentHint: originalCommentHint, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryDescriptionRequiredParams = { + policyID, + categoryName, + areCommentsRequired, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED, parameters, onyxData); +} + +function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: string, maxExpenseAmountNoReceipt: number) { + const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmountNoReceipt, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryReceiptsRequiredParams = { + policyID, + categoryName, + maxExpenseAmountNoReceipt, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_RECEIPTS_REQUIRED, parameters, onyxData); +} + +function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: string) { + const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmountNoReceipt: null, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + }, + }, + }, + ], + }; + + const parameters: RemovePolicyCategoryReceiptsRequiredParams = { + policyID, + categoryName, + }; + + API.write(WRITE_COMMANDS.REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED, parameters, onyxData); +} + function createPolicyCategory(policyID: string, categoryName: string) { const onyxData = buildOptimisticPolicyCategories(policyID, [categoryName]); @@ -307,7 +515,7 @@ function importPolicyCategories(policyID: string, categories: PolicyCategory[]) categories: JSON.stringify([...categories.map((category) => ({name: category.name, enabled: category.enabled, 'GL Code': String(category['GL Code'])}))]), }; - API.write(WRITE_COMMANDS.IMPORT_CATEGORIES_SREADSHEET, parameters, onyxData); + API.write(WRITE_COMMANDS.IMPORT_CATEGORIES_SPREADSHEET, parameters, onyxData); } function renamePolicyCategory(policyID: string, policyCategory: {oldName: string; newName: string}) { @@ -793,10 +1001,316 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); } +function downloadCategoriesCSV(policyID: string) { + const finalParameters = enhanceParameters(WRITE_COMMANDS.EXPORT_CATEGORIES_CSV, { + policyID, + }); + + const formData = new FormData(); + Object.entries(finalParameters).forEach(([key, value]) => { + formData.append(key, String(value)); + }); + + fileDownload(ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_CATEGORIES_CSV}), 'Categories.csv', '', false, formData, CONST.NETWORK.METHOD.POST); +} + +function setWorkspaceCategoryDescriptionHint(policyID: string, categoryName: string, commentHint: string) { + const originalCommentHint = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.commentHint; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + commentHint: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + commentHint, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + commentHint: null, + }, + commentHint, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + commentHint: null, + }, + commentHint: originalCommentHint, + }, + }, + }, + ], + }; + + const parameters: SetWorkspaceCategoryDescriptionHintParams = { + policyID, + categoryName, + commentHint, + }; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT, parameters, onyxData); +} + +function setPolicyCategoryMaxAmount(policyID: string, categoryName: string, maxExpenseAmount: string, expenseLimitType: PolicyCategoryExpenseLimitType) { + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; + const originalMaxExpenseAmount = policyCategoryToUpdate?.maxExpenseAmount; + const originalExpenseLimitType = policyCategoryToUpdate?.expenseLimitType; + const parsedMaxExpenseAmount = maxExpenseAmount === '' ? null : CurrencyUtils.convertToBackendAmount(parseFloat(maxExpenseAmount)); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + expenseLimitType: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmount: null, + expenseLimitType: null, + }, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmount: null, + expenseLimitType: null, + }, + maxExpenseAmount: originalMaxExpenseAmount, + expenseLimitType: originalExpenseLimitType, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryMaxAmountParams = { + policyID, + categoryName, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_MAX_AMOUNT, parameters, onyxData); +} + +function setPolicyCategoryApprover(policyID: string, categoryName: string, approver: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const approvalRules = policy?.rules?.approvalRules ?? []; + let updatedApprovalRules: ApprovalRule[] = lodashCloneDeep(approvalRules); + const existingCategoryApproverRule = updatedApprovalRules.find((rule) => rule.applyWhen.some((when) => when.value === categoryName)); + let newApprover = approver; + + if (!existingCategoryApproverRule) { + updatedApprovalRules.push({ + approver, + applyWhen: [ + { + condition: 'matches', + field: 'category', + value: categoryName, + }, + ], + }); + } else if (existingCategoryApproverRule?.approver === approver) { + updatedApprovalRules = updatedApprovalRules.filter((rule) => rule.approver !== approver); + newApprover = ''; + } else { + const indexToUpdate = updatedApprovalRules.indexOf(existingCategoryApproverRule); + updatedApprovalRules[indexToUpdate].approver = approver; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules: updatedApprovalRules, + pendingFields: { + approvalRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + pendingFields: { + approvalRules: null, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules, + pendingFields: { + approvalRules: null, + }, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryApproverParams = { + policyID, + categoryName, + approver: newApprover, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_APPROVER, parameters, onyxData); +} + +function setPolicyCategoryTax(policyID: string, categoryName: string, taxID: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const expenseRules = policy?.rules?.expenseRules ?? []; + const updatedExpenseRules: ExpenseRule[] = lodashCloneDeep(expenseRules); + const existingCategoryExpenseRule = updatedExpenseRules.find((rule) => rule.applyWhen.some((when) => when.value === categoryName)); + + if (!existingCategoryExpenseRule) { + updatedExpenseRules.push({ + tax: { + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + externalID: taxID, + }, + }, + applyWhen: [ + { + condition: 'matches', + field: 'category', + value: categoryName, + }, + ], + }); + } else { + const indexToUpdate = updatedExpenseRules.indexOf(existingCategoryExpenseRule); + updatedExpenseRules[indexToUpdate].tax.field_id_TAX.externalID = taxID; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + expenseRules: updatedExpenseRules, + pendingFields: { + expenseRules: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + pendingFields: { + expenseRules: null, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + expenseRules, + pendingFields: { + expenseRules: null, + }, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryTaxParams = { + policyID, + categoryName, + taxID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, parameters, onyxData); +} + export { openPolicyCategoriesPage, buildOptimisticPolicyRecentlyUsedCategories, setWorkspaceCategoryEnabled, + setPolicyCategoryDescriptionRequired, + setWorkspaceCategoryDescriptionHint, setWorkspaceRequiresCategory, setPolicyCategoryPayrollCode, createPolicyCategory, @@ -807,5 +1321,11 @@ export { setPolicyDistanceRatesDefaultCategory, deleteWorkspaceCategories, buildOptimisticPolicyCategories, + setPolicyCategoryReceiptsRequired, + removePolicyCategoryReceiptsRequired, + setPolicyCategoryMaxAmount, + setPolicyCategoryApprover, + setPolicyCategoryTax, importPolicyCategories, + downloadCategoriesCSV, }; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 018089ed62f0..cf20401644a7 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -13,8 +13,11 @@ import type { DeleteWorkspaceAvatarParams, DeleteWorkspaceParams, DisablePolicyBillableModeParams, + EnablePolicyAutoApprovalOptionsParams, + EnablePolicyAutoReimbursementLimitParams, EnablePolicyCompanyCardsParams, EnablePolicyConnectionsParams, + EnablePolicyDefaultReportTitleParams, EnablePolicyExpensifyCardsParams, EnablePolicyInvoicingParams, EnablePolicyReportFieldsParams, @@ -33,20 +36,26 @@ import type { OpenWorkspaceParams, OpenWorkspaceReimburseViewParams, RequestExpensifyCardLimitIncreaseParams, + SetPolicyAutomaticApprovalLimitParams, + SetPolicyAutomaticApprovalRateParams, + SetPolicyAutoReimbursementLimitParams, SetPolicyBillableModeParams, + SetPolicyDefaultReportTitleParams, + SetPolicyPreventMemberCreatedTitleParams, + SetPolicyPreventSelfApprovalParams, + SetPolicyRulesEnabledParams, SetWorkspaceApprovalModeParams, SetWorkspaceAutoReportingFrequencyParams, SetWorkspaceAutoReportingMonthlyOffsetParams, SetWorkspacePayerParams, SetWorkspaceReimbursementParams, + UpdatePolicyAddressParams, UpdateWorkspaceAvatarParams, UpdateWorkspaceCustomUnitAndRateParams, UpdateWorkspaceDescriptionParams, UpdateWorkspaceGeneralSettingsParams, UpgradeToCorporateParams, } from '@libs/API/parameters'; -import type SetPolicyRulesEnabledParams from '@libs/API/parameters/SetPolicyRulesEnabledParams'; -import type UpdatePolicyAddressParams from '@libs/API/parameters/UpdatePolicyAddressParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; @@ -217,11 +226,8 @@ function getPrimaryPolicy(activePolicyID: OnyxEntry<string>, currentUserLogin: s } /** Check if the policy has invoicing company details */ -// eslint-disable-next-line react/no-unused-prop-types,@typescript-eslint/no-unused-vars function hasInvoicingDetails(policy: OnyxEntry<Policy>): boolean { - // TODO: uncomment when invoicing details inside a policy are supported. - // return !!policy.invoice.companyName && !!policy.invoice.companyWebsite; - return true; + return !!policy?.invoice?.companyName && !!policy?.invoice?.companyWebsite; } /** @@ -377,6 +383,8 @@ function deleteWorkspace(policyID: string, policyName: string) { statusNum, oldPolicyName, policyName: report?.policyName, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: null, }, }); }); @@ -991,6 +999,7 @@ function updateWorkspaceAvatar(policyID: string, file: File) { * Deletes the avatar image for the workspace */ function deleteWorkspaceAvatar(policyID: string) { + const policy = getPolicy(policyID); const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1003,6 +1012,7 @@ function deleteWorkspaceAvatar(policyID: string) { avatarURL: null, }, avatarURL: '', + originalFileName: null, }, }, ]; @@ -1022,6 +1032,8 @@ function deleteWorkspaceAvatar(policyID: string) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { + avatarURL: policy?.avatarURL, + originalFileName: policy?.originalFileName, errorFields: { avatarURL: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('avatarWithImagePicker.deleteWorkspaceError'), }, @@ -3864,6 +3876,660 @@ function getAdminPoliciesConnectedToNetSuite(): Policy[] { return Object.values(allPolicies ?? {}).filter<Policy>((policy): policy is Policy => !!policy && policy.role === CONST.POLICY.ROLE.ADMIN && !!policy?.connections?.netsuite); } +/** + * Call the API to enable custom report title for the reports in the given policy + * @param policyID - id of the policy to apply the limit to + * @param enabled - whether custom report title for the reports is enabled in the given policy + */ +function enablePolicyDefaultReportTitle(policyID: string, enabled: boolean) { + const policy = getPolicy(policyID); + + if (enabled === policy?.shouldShowCustomReportTitleOption) { + return; + } + + const previousReportTitleField = policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID] ?? {}; + const titleFieldValues = enabled ? {} : {fieldList: {[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {...previousReportTitleField, defaultValue: CONST.POLICY.DEFAULT_REPORT_NAME_PATTERN}}}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + shouldShowCustomReportTitleOption: enabled, + ...titleFieldValues, + pendingFields: { + shouldShowCustomReportTitleOption: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + shouldShowCustomReportTitleOption: null, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + shouldShowCustomReportTitleOption: !!policy?.shouldShowCustomReportTitleOption, + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: previousReportTitleField, + }, + pendingFields: { + shouldShowCustomReportTitleOption: null, + }, + errorFields: { + shouldShowCustomReportTitleOption: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: EnablePolicyDefaultReportTitleParams = { + enable: enabled, + policyID, + }; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_DEFAULT_REPORT_TITLE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to set default report title pattern for the given policy + * @param policyID - id of the policy to apply the naming pattern to + * @param customName - name pattern to be used for the reports + */ +function setPolicyDefaultReportTitle(policyID: string, customName: string) { + const policy = getPolicy(policyID); + + if (customName === policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]?.defaultValue) { + return; + } + + const previousReportTitleField = policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID] ?? {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: { + defaultValue: customName, + pendingFields: {defaultValue: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {pendingFields: {defaultValue: null}}, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {...previousReportTitleField, pendingFields: {defaultValue: null}}, + }, + errorFields: { + fieldList: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyDefaultReportTitleParams = { + value: customName, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_DEFAULT_REPORT_TITLE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable or disable enforcing the naming pattern for member created reports on a policy + * @param policyID - id of the policy to apply the naming pattern to + * @param enforced - flag whether to enforce policy name + */ +function setPolicyPreventMemberCreatedTitle(policyID: string, enforced: boolean) { + const policy = getPolicy(policyID); + + if (!enforced === policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID].deletable) { + return; + } + + const previousReportTitleField = policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID] ?? {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {...previousReportTitleField, deletable: !enforced, pendingFields: {deletable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {pendingFields: {deletable: null}}, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID]: {...previousReportTitleField, pendingFields: {deletable: null}}, + }, + errorFields: { + fieldList: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyPreventMemberCreatedTitleParams = { + enforced, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_PREVENT_MEMBER_CREATED_TITLE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable or disable self approvals for the reports + * @param policyID - id of the policy to apply the naming pattern to + * @param preventSelfApproval - flag whether to prevent workspace members from approving their own expense reports + */ +function setPolicyPreventSelfApproval(policyID: string, preventSelfApproval: boolean) { + const policy = getPolicy(policyID); + + if (preventSelfApproval === policy?.preventSelfApproval) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + preventSelfApproval, + pendingFields: { + preventSelfApproval: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + preventSelfApproval: null, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + preventSelfApproval: policy?.preventSelfApproval ?? false, + pendingFields: { + preventSelfApproval: null, + }, + errorFields: { + preventSelfApproval: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyPreventSelfApprovalParams = { + preventSelfApproval, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_PREVENT_SELF_APPROVAL, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to apply automatic approval limit for the given policy + * @param policyID - id of the policy to apply the limit to + * @param limit - max amount for auto-approval of the reports in the given policy + */ +function setPolicyAutomaticApprovalLimit(policyID: string, limit: string) { + const policy = getPolicy(policyID); + + const fallbackLimit = limit === '' ? '0' : limit; + const parsedLimit = CurrencyUtils.convertToBackendAmount(parseFloat(fallbackLimit)); + + if (parsedLimit === policy?.autoApproval?.limit ?? CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + limit: parsedLimit, + pendingFields: {limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + pendingFields: { + limit: null, + }, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + limit: policy?.autoApproval?.limit ?? CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS, + pendingFields: { + limit: null, + }, + }, + errorFields: { + autoApproval: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyAutomaticApprovalLimitParams = { + limit: parsedLimit, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_LIMIT, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to set the audit rate for the given policy + * @param policyID - id of the policy to apply the limit to + * @param auditRate - percentage of the reports to be qualified for a random audit + */ +function setPolicyAutomaticApprovalRate(policyID: string, auditRate: string) { + const policy = getPolicy(policyID); + const fallbackAuditRate = auditRate === '' ? '0' : auditRate; + const parsedAuditRate = parseInt(fallbackAuditRate, 10); + + if (parsedAuditRate === policy?.autoApproval?.auditRate ?? CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + auditRate: parsedAuditRate, + pendingFields: { + auditRate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + pendingFields: { + auditRate: null, + }, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + auditRate: policy?.autoApproval?.auditRate ?? CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE, + pendingFields: { + auditRate: null, + }, + }, + errorFields: { + autoApproval: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyAutomaticApprovalRateParams = { + auditRate: parsedAuditRate, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_RATE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable auto-approval for the reports in the given policy + * @param policyID - id of the policy to apply the limit to + * @param enabled - whether auto-approve for the reports is enabled in the given policy + */ +function enableAutoApprovalOptions(policyID: string, enabled: boolean) { + const policy = getPolicy(policyID); + + if (enabled === policy?.shouldShowAutoApprovalOptions) { + return; + } + + const autoApprovalCleanupValues = !enabled + ? { + pendingFields: { + limit: null, + auditRate: null, + }, + } + : {}; + const autoApprovalValues = !enabled ? {auditRate: CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE, limit: CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS} : {}; + const autoApprovalFailureValues = !enabled ? {autoApproval: {limit: policy?.autoApproval?.limit, auditRate: policy?.autoApproval?.auditRate, ...autoApprovalCleanupValues}} : {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + ...autoApprovalValues, + pendingFields: { + limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + auditRate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + shouldShowAutoApprovalOptions: enabled, + pendingFields: { + shouldShowAutoApprovalOptions: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: {...autoApprovalCleanupValues}, + pendingFields: { + shouldShowAutoApprovalOptions: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + ...autoApprovalFailureValues, + shouldShowAutoApprovalOptions: policy?.shouldShowAutoApprovalOptions, + pendingFields: { + shouldShowAutoApprovalOptions: null, + }, + }, + }, + ]; + + const parameters: EnablePolicyAutoApprovalOptionsParams = { + enabled, + policyID, + }; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_AUTO_APPROVAL_OPTIONS, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to set the limit for auto-payments in the given policy + * @param policyID - id of the policy to apply the limit to + * @param limit - max amount for auto-payment for the reports in the given policy + */ +function setPolicyAutoReimbursementLimit(policyID: string, limit: string) { + const policy = getPolicy(policyID); + const fallbackLimit = limit === '' ? '0' : limit; + const parsedLimit = CurrencyUtils.convertToBackendAmount(parseFloat(fallbackLimit)); + + if (parsedLimit === policy?.autoReimbursement?.limit ?? CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: { + limit: parsedLimit, + pendingFields: { + limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: { + limit: parsedLimit, + pendingFields: { + limit: null, + }, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: {limit: policy?.autoReimbursement?.limit ?? policy?.autoReimbursementLimit, pendingFields: {limit: null}}, + errorFields: { + autoReimbursement: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyAutoReimbursementLimitParams = { + autoReimbursement: {limit: parsedLimit}, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_AUTO_REIMBURSEMENT_LIMIT, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable auto-payment for the reports in the given policy + * @param policyID - id of the policy to apply the limit to + * @param enabled - whether auto-payment for the reports is enabled in the given policy + */ +function enablePolicyAutoReimbursementLimit(policyID: string, enabled: boolean) { + const policy = getPolicy(policyID); + + if (enabled === policy?.shouldShowAutoReimbursementLimitOption) { + return; + } + + const autoReimbursementCleanupValues = !enabled + ? { + pendingFields: { + limit: null, + }, + } + : {}; + const autoReimbursementFailureValues = !enabled ? {autoReimbursement: {limit: policy?.autoReimbursement?.limit, ...autoReimbursementCleanupValues}} : {}; + const autoReimbursementValues = !enabled ? {limit: CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS} : {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: { + ...autoReimbursementValues, + pendingFields: { + limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + shouldShowAutoReimbursementLimitOption: enabled, + pendingFields: { + shouldShowAutoReimbursementLimitOption: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: {...autoReimbursementCleanupValues}, + pendingFields: { + shouldShowAutoReimbursementLimitOption: null, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + ...autoReimbursementFailureValues, + shouldShowAutoReimbursementLimitOption: policy?.shouldShowAutoReimbursementLimitOption, + pendingFields: { + shouldShowAutoReimbursementLimitOption: null, + }, + }, + }, + ]; + + const parameters: EnablePolicyAutoReimbursementLimitParams = { + enabled, + policyID, + }; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_AUTO_REIMBURSEMENT_LIMIT, parameters, { + optimisticData, + successData, + failureData, + }); +} + function setWorkspaceCompanyCardFeedName(policyID: string, workspaceAccountID: number, bankName: string, userDefinedName: string) { const authToken = NetworkStore.getAuthToken(); const onyxData: OnyxData = { @@ -3914,6 +4580,7 @@ function setWorkspaceCompanyCardTransactionLiability(workspaceAccountID: number, API.write(WRITE_COMMANDS.SET_COMPANY_CARD_TRANSACTION_LIABILITY, parameters, onyxData); } + function deleteWorkspaceCompanyCardFeed(policyID: string, workspaceAccountID: number, bankName: string) { const authToken = NetworkStore.getAuthToken(); @@ -4032,6 +4699,15 @@ export { hasInvoicingDetails, clearAllPolicies, enablePolicyRules, + setPolicyDefaultReportTitle, + setPolicyPreventMemberCreatedTitle, + setPolicyPreventSelfApproval, + setPolicyAutomaticApprovalLimit, + setPolicyAutomaticApprovalRate, + setPolicyAutoReimbursementLimit, + enablePolicyDefaultReportTitle, + enablePolicyAutoReimbursementLimit, + enableAutoApprovalOptions, setPolicyMaxExpenseAmountNoReceipt, setPolicyMaxExpenseAmount, setPolicyMaxExpenseAge, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index bd31d3832605..147b07f650b2 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -8,7 +8,6 @@ import Onyx from 'react-native-onyx'; import type {PartialDeep, ValueOf} from 'type-fest'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; -import AccountUtils from '@libs/AccountUtils'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import * as API from '@libs/API'; import type { @@ -111,6 +110,7 @@ import * as Modal from './Modal'; import navigateFromNotification from './navigateFromNotification'; import * as Session from './Session'; import * as Welcome from './Welcome'; +import * as OnboardingFlow from './Welcome/OnboardingFlow'; type SubscriberCallback = (isFromCurrentUser: boolean, reportActionID: string | undefined) => void; @@ -493,7 +493,9 @@ function addActions(reportID: string, text = '', file?: FileObject) { const shouldUpdateNotificationPrefernece = !isEmptyObject(report) && ReportUtils.getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; if (shouldUpdateNotificationPrefernece) { - optimisticReport.notificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; + optimisticReport.participants = { + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }; } // Optimistically add the new actions to the store before waiting to save them to the server @@ -553,7 +555,9 @@ function addActions(reportID: string, text = '', file?: FileObject) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + participants: { + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, }, }); } @@ -1007,7 +1011,7 @@ function navigateToAndOpenReport( if (isEmptyObject(chat)) { if (isGroupChat) { // If we are creating a group chat then participantAccountIDs is expected to contain currentUserAccountID - newChat = ReportUtils.buildOptimisticGroupChatReport(participantAccountIDs, reportName ?? '', avatarUri ?? '', optimisticReportID, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + newChat = ReportUtils.buildOptimisticGroupChatReport(participantAccountIDs, reportName ?? '', avatarUri ?? '', optimisticReportID, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS); } else { newChat = ReportUtils.buildOptimisticChatReport( [...participantAccountIDs, currentUserAccountID], @@ -1019,7 +1023,7 @@ function navigateToAndOpenReport( undefined, undefined, undefined, - CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, ); } } @@ -1696,7 +1700,6 @@ function updateNotificationPreference( parentReportID?: string, parentReportActionID?: string, report?: OnyxEntry<Report>, - isJoiningRoom?: boolean, ) { if (previousValue === newValue) { if (navigate && !isEmptyObject(report) && report.reportID) { @@ -1709,7 +1712,13 @@ function updateNotificationPreference( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: {notificationPreference: newValue}, + value: { + participants: { + [currentUserAccountID]: { + notificationPreference: newValue, + }, + }, + }, }, ]; @@ -1717,7 +1726,13 @@ function updateNotificationPreference( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: {notificationPreference: previousValue}, + value: { + participants: { + [currentUserAccountID]: { + notificationPreference: previousValue, + }, + }, + }, }, ]; @@ -1734,20 +1749,6 @@ function updateNotificationPreference( }); } - if (isJoiningRoom) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: { - participants: { - [currentUserAccountID]: { - hidden: false, - }, - }, - }, - }); - } - const parameters: UpdateReportNotificationPreferenceParams = {reportID, notificationPreference: newValue}; API.write(WRITE_COMMANDS.UPDATE_REPORT_NOTIFICATION_PREFERENCE, parameters, {optimisticData, failureData}); @@ -2164,17 +2165,6 @@ function navigateToConciergeChat(shouldDismissModal = false, checkIfCurrentPageA } } -/** - * Navigates to the 1:1 system chat - */ -function navigateToSystemChat() { - const systemChatReport = ReportUtils.getSystemChat(); - - if (systemChatReport?.reportID) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(systemChatReport.reportID)); - } -} - /** Add a policy report (workspace room) optimistically and navigate to it. */ function addPolicyReport(policyReport: ReportUtils.OptimisticChatReport) { const createdReportAction = ReportUtils.buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE); @@ -2428,7 +2418,7 @@ function shouldShowReportActionNotification(reportID: string, action: ReportActi } // We don't want to send a local notification if the user preference is daily, mute or hidden. - const notificationPreference = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.notificationPreference ?? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; + const notificationPreference = ReportUtils.getReportNotificationPreference(ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]); if (notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS) { Log.info(`${tag} No notification because user preference is to be notified: ${notificationPreference}`); return false; @@ -2704,7 +2694,9 @@ function openReportFromDeepLink(url: string) { // We need skip deeplinking if the user hasn't completed the guided setup flow. if (!hasCompletedGuidedSetupFlow) { - Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT.getRoute())}); + Welcome.isOnboardingFlowCompleted({ + onNotCompleted: () => OnboardingFlow.startOnboardingFlow(), + }); return; } @@ -2758,13 +2750,12 @@ function joinRoom(report: OnyxEntry<Report>) { } updateNotificationPreference( report.reportID, - report.notificationPreference, + ReportUtils.getReportNotificationPreference(report), CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, false, report.parentReportID, report.parentReportActionID, report, - true, ); } @@ -2819,10 +2810,9 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal value: isWorkspaceMemberLeavingWorkspaceRoom || isChatThread ? { - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, participants: { [currentUserAccountID]: { - hidden: true, + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, }, }, } @@ -2830,7 +2820,11 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal reportID: null, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.CLOSED, - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + participants: { + [currentUserAccountID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + }, }, }, ]; @@ -2841,7 +2835,13 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: isWorkspaceMemberLeavingWorkspaceRoom || isChatThread - ? {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN} + ? { + participants: { + [currentUserAccountID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + }, + } : Object.keys(report).reduce<Record<string, null>>((acc, key) => { acc[key] = null; return acc; @@ -2871,7 +2871,7 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, - value: {[report.parentReportActionID]: {childReportNotificationPreference: report.notificationPreference}}, + value: {[report.parentReportActionID]: {childReportNotificationPreference: ReportUtils.getReportNotificationPreference(report)}}, }); } @@ -2897,6 +2897,8 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails return; } + const defaultNotificationPreference = ReportUtils.getDefaultNotificationPreferenceForReport(report); + const inviteeEmails = Object.keys(inviteeEmailsToAccountIDs); const inviteeAccountIDs = Object.values(inviteeEmailsToAccountIDs); @@ -2906,7 +2908,7 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails const participantsAfterInvitation = inviteeAccountIDs.reduce( (reportParticipants: Participants, accountID: number) => { const participant: ReportParticipant = { - hidden: false, + notificationPreference: defaultNotificationPreference, role: CONST.REPORT.ROLE.MEMBER, }; // eslint-disable-next-line no-param-reassign @@ -3009,8 +3011,8 @@ function clearAddRoomMemberError(reportID: string, invitedAccountID: string) { function updateGroupChatMemberRoles(reportID: string, accountIDList: number[], role: ValueOf<typeof CONST.REPORT.ROLE>) { const memberRoles: Record<number, string> = {}; - const optimisticParticipants: Participants = {}; - const successParticipants: Participants = {}; + const optimisticParticipants: Record<number, Partial<ReportParticipant>> = {}; + const successParticipants: Record<number, Partial<ReportParticipant>> = {}; accountIDList.forEach((accountID) => { memberRoles[accountID] = role; @@ -3328,14 +3330,9 @@ function completeOnboarding( }, adminsChatReportID?: string, onboardingPolicyID?: string, + paymentSelected?: string, ) { - const isAccountIDOdd = AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? 0); - const targetEmail = isAccountIDOdd ? CONST.EMAIL.NOTIFICATIONS : CONST.EMAIL.CONCIERGE; - - // If the target report isn't opened, the permission field will not exist. So we should add the fallback permission for task report - const fallbackPermission = isAccountIDOdd ? [CONST.REPORT.PERMISSIONS.READ] : [CONST.REPORT.PERMISSIONS.READ, CONST.REPORT.PERMISSIONS.WRITE]; - - const actorAccountID = PersonalDetailsUtils.getAccountIDsByLogins([targetEmail])[0]; + const actorAccountID = CONST.ACCOUNT_ID.CONCIERGE; const targetChatReport = ReportUtils.getChatByParticipants([actorAccountID, currentUserAccountID]); const {reportID: targetChatReportID = '', policyID: targetChatPolicyID = ''} = targetChatReport ?? {}; @@ -3359,7 +3356,7 @@ function completeOnboarding( let videoCommentAction: OptimisticAddCommentReportAction | null = null; let videoMessage: AddCommentOrAttachementParams | null = null; - if (data.video) { + if ('video' in data && data.video) { const videoComment = ReportUtils.buildOptimisticAddCommentReportAction(CONST.ATTACHMENT_MESSAGE_TEXT, undefined, actorAccountID, 2); videoCommentAction = videoComment.reportAction; videoMessage = { @@ -3374,7 +3371,9 @@ function completeOnboarding( typeof task.description === 'function' ? task.description({ adminsRoomLink: `${environmentURL}/${ROUTES.REPORT_WITH_ID.getRoute(adminsChatReportID ?? '-1')}`, - workspaceLink: `${environmentURL}/${ROUTES.WORKSPACE_INITIAL.getRoute(onboardingPolicyID ?? '-1')}`, + workspaceCategoriesLink: `${environmentURL}/${ROUTES.WORKSPACE_CATEGORIES.getRoute(onboardingPolicyID ?? '-1')}`, + workspaceMembersLink: `${environmentURL}/${ROUTES.WORKSPACE_MEMBERS.getRoute(onboardingPolicyID ?? '-1')}`, + workspaceMoreFeaturesLink: `${environmentURL}/${ROUTES.WORKSPACE_MORE_FEATURES.getRoute(onboardingPolicyID ?? '-1')}`, }) : task.description; const currentTask = ReportUtils.buildOptimisticTaskReport( @@ -3386,7 +3385,7 @@ function completeOnboarding( targetChatPolicyID, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, ); - const taskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(targetEmail); + const taskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(CONST.EMAIL.CONCIERGE); const taskReportAction = ReportUtils.buildOptimisticTaskCommentReportAction( currentTask.reportID, task.title, @@ -3450,7 +3449,6 @@ function completeOnboarding( }, isOptimisticReport: true, managerID: currentUserAccountID, - permissions: targetChatReport?.permissions ?? fallbackPermission, }, }, { @@ -3640,7 +3638,7 @@ function completeOnboarding( {type: 'message', ...textMessage}, ]; - if (data.video && videoCommentAction && videoMessage) { + if ('video' in data && data.video && videoCommentAction && videoMessage) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, @@ -3678,6 +3676,7 @@ function completeOnboarding( lastName, actorAccountID, guidedSetupData: JSON.stringify(guidedSetupData), + paymentSelected, }; API.write(WRITE_COMMANDS.COMPLETE_GUIDED_SETUP, parameters, {optimisticData, successData, failureData}); @@ -4088,6 +4087,8 @@ function markAsManuallyExported(reportID: string, connectionName: ConnectionName API.write(WRITE_COMMANDS.MARK_AS_EXPORTED, params, {optimisticData, successData, failureData}); } +export type {Video}; + export { searchInServer, addComment, @@ -4109,7 +4110,6 @@ export { saveReportActionDraft, deleteReportComment, navigateToConciergeChat, - navigateToSystemChat, addPolicyReport, deleteReport, navigateToConciergeChatAndDeleteReport, diff --git a/src/libs/actions/RoomMembersUserSearchPhrase.ts b/src/libs/actions/RoomMembersUserSearchPhrase.ts new file mode 100644 index 000000000000..1395b1ecec7f --- /dev/null +++ b/src/libs/actions/RoomMembersUserSearchPhrase.ts @@ -0,0 +1,15 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function clearUserSearchPhrase() { + Onyx.merge(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE, ''); +} + +/** + * Persists user search phrase from the serch input across the screens. + */ +function updateUserSearchPhrase(value: string) { + Onyx.merge(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE, value); +} + +export {clearUserSearchPhrase, updateUserSearchPhrase}; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 020fc1bd2c30..e522aea788e4 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1,5 +1,6 @@ import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import type {FormOnyxValues} from '@components/Form/types'; import type {SearchQueryJSON} from '@components/Search/types'; import * as API from '@libs/API'; @@ -10,6 +11,7 @@ import fileDownload from '@libs/fileDownload'; import enhanceParameters from '@libs/Network/enhanceParameters'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; import type {SearchTransaction} from '@src/types/onyx/SearchResults'; import * as Report from './Report'; @@ -51,9 +53,9 @@ function getOnyxLoadingData(hash: number): {optimisticData: OnyxUpdate[]; finall function search({queryJSON, offset}: {queryJSON: SearchQueryJSON; offset?: number}) { const {optimisticData, finallyData} = getOnyxLoadingData(queryJSON.hash); - + const {flatFilters, ...queryJSONWithoutFlatFilters} = queryJSON; const queryWithOffset = { - ...queryJSON, + ...queryJSONWithoutFlatFilters, offset, }; const jsonQuery = JSON.stringify(queryWithOffset); @@ -128,10 +130,21 @@ function updateAdvancedFilters(values: Partial<FormOnyxValues<typeof ONYXKEYS.FO /** * Clears all values for the advanced filters search form. */ -function clearAdvancedFilters() { +function clearAllFilters() { Onyx.set(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, null); } +function clearAdvancedFilters() { + const values: Partial<Record<ValueOf<typeof FILTER_KEYS>, null>> = {}; + Object.values(FILTER_KEYS) + .filter((key) => key !== FILTER_KEYS.TYPE && key !== FILTER_KEYS.STATUS) + .forEach((key) => { + values[key] = null; + }); + + Onyx.merge(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, values); +} + export { search, createTransactionThread, @@ -140,5 +153,6 @@ export { unholdMoneyRequestOnSearch, exportSearchItemsToCSV, updateAdvancedFilters, + clearAllFilters, clearAdvancedFilters, }; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 1d7e695fa2e3..ab209e9bf928 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -52,8 +52,8 @@ import type {HybridAppRoute, Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type Credentials from '@src/types/onyx/Credentials'; -import type {AutoAuthState} from '@src/types/onyx/Session'; import type Session from '@src/types/onyx/Session'; +import type {AutoAuthState} from '@src/types/onyx/Session'; import clearCache from './clearCache'; import updateSessionAuthTokens from './updateSessionAuthTokens'; @@ -420,6 +420,21 @@ function beginSignIn(email: string) { API.read(READ_COMMANDS.BEGIN_SIGNIN, params, {optimisticData, successData, failureData}); } +/** + * Create Onyx update to clean up anonymous user data + */ +function buildOnyxDataToCleanUpAnonymousUser() { + const data: Record<string, null> = {}; + if (session.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS && session.accountID) { + data[session.accountID] = null; + } + return { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: data, + onyxMethod: Onyx.METHOD.MERGE, + }; +} + /** * Creates an account for the new user and signs them into the application with the newly created account. * @@ -436,6 +451,8 @@ function signUpUser() { }, ]; + const onyxOperationToCleanUpAnonymousUser = buildOnyxDataToCleanUpAnonymousUser(); + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -444,6 +461,7 @@ function signUpUser() { isLoading: false, }, }, + onyxOperationToCleanUpAnonymousUser, ]; const failureData: OnyxUpdate[] = [ @@ -464,7 +482,7 @@ function signUpUser() { function signInAfterTransitionFromOldDot(transitionURL: string) { const [route, queryParams] = transitionURL.split('?'); - const {email, authToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart} = Object.fromEntries( + const {email, authToken, encryptedAuthToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart} = Object.fromEntries( queryParams.split('&').map((param) => { const [key, value] = param.split('='); return [key, value]; @@ -473,7 +491,7 @@ function signInAfterTransitionFromOldDot(transitionURL: string) { const setSessionDataAndOpenApp = () => { Onyx.multiSet({ - [ONYXKEYS.SESSION]: {email, authToken, accountID: Number(accountID)}, + [ONYXKEYS.SESSION]: {email, authToken, encryptedAuthToken: decodeURIComponent(encryptedAuthToken), accountID: Number(accountID)}, [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword}, }).then(App.openApp); }; @@ -545,6 +563,8 @@ function signIn(validateCode: string, twoFactorAuthCode?: string) { }, ]; + const onyxOperationToCleanUpAnonymousUser = buildOnyxDataToCleanUpAnonymousUser(); + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -561,6 +581,7 @@ function signIn(validateCode: string, twoFactorAuthCode?: string) { validateCode, }, }, + onyxOperationToCleanUpAnonymousUser, ]; const failureData: OnyxUpdate[] = [ @@ -595,6 +616,7 @@ function signInWithValidateCode(accountID: number, code: string, twoFactorAuthCo // If this is called from the 2fa step, get the validateCode directly from onyx // instead of the one passed from the component state because the state is changing when this method is called. const validateCode = twoFactorAuthCode ? credentials.validateCode : code; + const onyxOperationToCleanUpAnonymousUser = buildOnyxDataToCleanUpAnonymousUser(); const optimisticData: OnyxUpdate[] = [ { @@ -635,6 +657,7 @@ function signInWithValidateCode(accountID: number, code: string, twoFactorAuthCo key: ONYXKEYS.SESSION, value: {autoAuthState: CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN}, }, + onyxOperationToCleanUpAnonymousUser, ]; const failureData: OnyxUpdate[] = [ diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index dc3b6781ee83..b2ac3992023e 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -25,7 +25,7 @@ import type ReportAction from '@src/types/onyx/ReportAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as Report from './Report'; -type OptimisticReport = Pick<OnyxTypes.Report, 'reportName' | 'managerID' | 'notificationPreference' | 'pendingFields' | 'participants'>; +type OptimisticReport = Pick<OnyxTypes.Report, 'reportName' | 'managerID' | 'pendingFields' | 'participants'>; type Assignee = { icons: Icon[]; displayName: string; @@ -581,9 +581,13 @@ function editTaskAssignee(report: OnyxTypes.Report, ownerAccountID: number, assi pendingFields: { ...(assigneeAccountID && {managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, - notificationPreference: [assigneeAccountID, ownerAccountID].includes(currentUserAccountID) - ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS - : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + participants: { + [currentUserAccountID]: { + notificationPreference: [assigneeAccountID, ownerAccountID].includes(currentUserAccountID) + ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS + : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + }, }; const successReport: NullishDeep<OnyxTypes.Report> = {pendingFields: {...(assigneeAccountID && {managerID: null})}}; @@ -662,7 +666,12 @@ function editTaskAssignee(report: OnyxTypes.Report, ownerAccountID: number, assi // If we make a change to the assignee, we want to add a comment to the assignee's chat // Check if the assignee actually changed if (assigneeAccountID && assigneeAccountID !== report.managerID && assigneeAccountID !== ownerAccountID && assigneeChatReport) { - optimisticReport.participants = {[assigneeAccountID]: {hidden: false}}; + optimisticReport.participants = { + ...(optimisticReport.participants ?? {}), + [assigneeAccountID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, + }; assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData( currentUserAccountID, diff --git a/src/libs/actions/Timing.ts b/src/libs/actions/Timing.ts index 6244ab2a609b..edb751b33a4b 100644 --- a/src/libs/actions/Timing.ts +++ b/src/libs/actions/Timing.ts @@ -20,13 +20,11 @@ let timestampData: Record<string, TimestampData> = {}; * @param shouldUseFirebase - adds an additional trace in Firebase */ function start(eventName: string, shouldUseFirebase = true) { - timestampData[eventName] = {startTime: Date.now(), shouldUseFirebase}; - - if (!shouldUseFirebase) { - return; + if (shouldUseFirebase) { + Firebase.startTrace(eventName); } - Firebase.startTrace(eventName); + timestampData[eventName] = {startTime: performance.now(), shouldUseFirebase}; } /** @@ -42,13 +40,14 @@ function end(eventName: string, secondaryName = '', maxExecutionTime = 0) { } const {startTime, shouldUseFirebase} = timestampData[eventName]; - Environment.getEnvironment().then((envName) => { - const eventTime = Date.now() - startTime; - if (shouldUseFirebase) { - Firebase.stopTrace(eventName); - } + const eventTime = performance.now() - startTime; + if (shouldUseFirebase) { + Firebase.stopTrace(eventName); + } + + Environment.getEnvironment().then((envName) => { const baseEventName = `${envName}.new.expensify.${eventName}`; const grafanaEventName = secondaryName ? `${baseEventName}.${secondaryName}` : baseEventName; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 8f776a70903d..2e4fb5c770d0 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -269,6 +269,17 @@ function clearContactMethod(contactMethod: string) { }); } +/** + * Clears error for a sepcific field on validate action code. + */ +function clearValidateCodeActionError(fieldName: string) { + Onyx.merge(ONYXKEYS.VALIDATE_ACTION_CODE, { + errorFields: { + [fieldName]: null, + }, + }); +} + /** * Clears any possible stored errors for a specific field on a contact method */ @@ -309,6 +320,16 @@ function clearUnvalidatedNewContactMethodAction() { }); } +/** + * When user adds a new contact method, they need to verify the magic code first + * So we add the temporary contact method to Onyx to use it later, after user verified magic code. + */ +function addPendingContactMethod(contactMethod: string) { + Onyx.merge(ONYXKEYS.PENDING_CONTACT_ACTION, { + contactMethod, + }); +} + /** * Validates the action to add secondary contact method */ @@ -470,6 +491,63 @@ function addNewContactMethod(contactMethod: string, validateCode = '') { API.write(WRITE_COMMANDS.ADD_NEW_CONTACT_METHOD, parameters, {optimisticData, successData, failureData}); } +/** + * Requests a magic code to verify current user + */ +function requestValidateCodeAction() { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.VALIDATE_ACTION_CODE, + value: { + isLoading: true, + pendingFields: { + actionVerified: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + errorFields: { + actionVerified: null, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.VALIDATE_ACTION_CODE, + value: { + validateCodeSent: true, + isLoading: false, + errorFields: { + actionVerified: null, + }, + pendingFields: { + actionVerified: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.VALIDATE_ACTION_CODE, + value: { + validateCodeSent: null, + isLoading: false, + errorFields: { + actionVerified: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('contacts.genericFailureMessages.requestContactMethodValidateCode'), + }, + pendingFields: { + actionVerified: null, + }, + }, + }, + ]; + + API.write(WRITE_COMMANDS.RESEND_VALIDATE_CODE, null, {optimisticData, successData, failureData}); +} + /** * Validates a login given an accountID and validation code */ @@ -679,11 +757,10 @@ const isChannelMuted = (reportId: string) => key: `${ONYXKEYS.COLLECTION.REPORT}${reportId}`, callback: (report) => { Onyx.disconnect(connection); + const notificationPreference = report?.participants?.[currentUserAccountID]?.notificationPreference; resolve( - !report?.notificationPreference || - report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE || - report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + !notificationPreference || notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE || notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, ); }, }); @@ -1231,6 +1308,10 @@ function dismissWorkspaceTooltip() { Onyx.merge(ONYXKEYS.NVP_WORKSPACE_TOOLTIP, {shouldShow: false}); } +function dismissGBRTooltip() { + Onyx.merge(ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP, true); +} + function requestRefund() { API.write(WRITE_COMMANDS.REQUEST_REFUND, null); } @@ -1270,4 +1351,8 @@ export { requestRefund, saveNewContactMethodAndRequestValidationCode, clearUnvalidatedNewContactMethodAction, + requestValidateCodeAction, + addPendingContactMethod, + clearValidateCodeActionError, + dismissGBRTooltip, }; diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts new file mode 100644 index 000000000000..4e780090299d --- /dev/null +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -0,0 +1,126 @@ +import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; +import type {NavigationState, PartialState} from '@react-navigation/native'; +import Onyx from 'react-native-onyx'; +import linkingConfig from '@libs/Navigation/linkingConfig'; +import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import type {NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +let selectedPurpose: string | undefined = ''; +Onyx.connect({ + key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, + callback: (value) => { + selectedPurpose = value; + }, +}); + +let onboardingInitialPath = ''; +const onboardingLastVisitedPathConnection = Onyx.connect({ + key: ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, + callback: (value) => { + if (value === undefined) { + return; + } + onboardingInitialPath = value; + Onyx.disconnect(onboardingLastVisitedPathConnection); + }, +}); + +/** + * Build the correct stack order for `onboardingModalNavigator`, + * based on onboarding data (currently from the selected purpose). + * The correct stack order will ensure that navigation and + * the `goBack` navigatoin work properly. + */ +function adaptOnboardingRouteState() { + const currentRoute: NavigationPartialRoute | undefined = navigationRef.getCurrentRoute(); + if (!currentRoute || currentRoute?.name === SCREENS.ONBOARDING.PURPOSE) { + return; + } + + const rootState = navigationRef.getRootState(); + const adaptedState = rootState; + const lastRouteIndex = (adaptedState?.routes?.length ?? 0) - 1; + const onBoardingModalNavigatorState = adaptedState?.routes[lastRouteIndex]?.state; + if (!onBoardingModalNavigatorState || onBoardingModalNavigatorState?.routes?.length > 1) { + return; + } + + let adaptedOnboardingModalNavigatorState = {} as Readonly<PartialState<NavigationState>>; + if (currentRoute?.name === SCREENS.ONBOARDING.PERSONAL_DETAILS && selectedPurpose === CONST.ONBOARDING_CHOICES.MANAGE_TEAM) { + adaptedOnboardingModalNavigatorState = { + index: 2, + routes: [ + { + name: SCREENS.ONBOARDING.PURPOSE, + params: currentRoute?.params, + }, + { + name: SCREENS.ONBOARDING.WORK, + params: currentRoute?.params, + }, + {...currentRoute}, + ], + } as Readonly<PartialState<NavigationState>>; + } else { + adaptedOnboardingModalNavigatorState = { + index: 1, + routes: [ + { + name: SCREENS.ONBOARDING.PURPOSE, + params: currentRoute?.params, + }, + {...currentRoute}, + ], + } as Readonly<PartialState<NavigationState>>; + } + + adaptedState.routes[lastRouteIndex].state = adaptedOnboardingModalNavigatorState; + navigationRef.resetRoot(adaptedState); +} + +/** + * Start a new onboarding flow or continue from the last visited onboarding page. + */ +function startOnboardingFlow() { + const currentRoute = navigationRef.getCurrentRoute(); + const {adaptedState} = getAdaptedStateFromPath(getOnboardingInitialPath(), linkingConfig.config, false); + const focusedRoute = findFocusedRoute(adaptedState as PartialState<NavigationState<RootStackParamList>>); + if (focusedRoute?.name === currentRoute?.name) { + return; + } + navigationRef.resetRoot(adaptedState); +} + +function getOnboardingInitialPath(): string { + const state = getStateFromPath(onboardingInitialPath, linkingConfig.config); + if (state?.routes?.at(-1)?.name !== NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR) { + return `/${ROUTES.ONBOARDING_ROOT.route}`; + } + + return onboardingInitialPath; +} + +function clearInitialPath() { + onboardingInitialPath = ''; +} + +/** + * Onboarding flow: Go back to the previous page. + * Since there is no `initialRoute` for `onBoardingModalNavigator`, + * firstly, adjust the current onboarding modal navigator to establish the correct stack order. + * Then, navigate to the previous onboarding page using the usual `goBack` function. + */ +function goBack() { + adaptOnboardingRouteState(); + Navigation.isNavigationReady().then(() => { + Navigation.goBack(); + }); +} + +export {getOnboardingInitialPath, startOnboardingFlow, clearInitialPath, goBack}; diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome/index.ts similarity index 92% rename from src/libs/actions/Welcome.ts rename to src/libs/actions/Welcome/index.ts index d54314ae6f05..f5995aa1e2a9 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome/index.ts @@ -10,6 +10,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type Onboarding from '@src/types/onyx/Onboarding'; import type TryNewDot from '@src/types/onyx/TryNewDot'; +import * as OnboardingFlow from './OnboardingFlow'; type OnboardingData = Onboarding | [] | undefined; @@ -46,6 +47,7 @@ function onServerDataReady(): Promise<void> { return isServerDataReadyPromise; } +let isOnboardingInProgress = false; function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOnboardingFlowProps) { isOnboardingFlowStatusKnownPromise.then(() => { if (Array.isArray(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) { @@ -53,8 +55,10 @@ function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOn } if (onboarding?.hasCompletedGuidedSetupFlow) { + isOnboardingInProgress = false; onCompleted?.(); - } else { + } else if (!isOnboardingInProgress) { + isOnboardingInProgress = true; onNotCompleted?.(); } }); @@ -97,7 +101,7 @@ function handleHybridAppOnboarding() { isOnboardingFlowCompleted({ onNotCompleted: () => setTimeout(() => { - Navigation.navigate(ROUTES.ONBOARDING_ROOT.route); + OnboardingFlow.startOnboardingFlow(); }, variables.explanationModalDelay), }), }); @@ -152,6 +156,10 @@ function setOnboardingPolicyID(policyID?: string) { Onyx.set(ONYXKEYS.ONBOARDING_POLICY_ID, policyID ?? null); } +function updateOnboardingLastVisitedPath(path: string) { + Onyx.merge(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, path); +} + function completeHybridAppOnboarding() { const optimisticData: OnyxUpdate[] = [ { @@ -213,12 +221,15 @@ function resetAllChecks() { resolveOnboardingFlowStatus = resolve; }); isLoadingReportData = true; + isOnboardingInProgress = false; + OnboardingFlow.clearInitialPath(); } export { onServerDataReady, isOnboardingFlowCompleted, setOnboardingPurposeSelected, + updateOnboardingLastVisitedPath, resetAllChecks, setOnboardingAdminsChatReportID, setOnboardingPolicyID, diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 4adb692919ee..5e4b0408f155 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -37,11 +37,11 @@ Onyx.connect({ }, }); -let personalDetails: PersonalDetailsList | undefined; +let personalDetailsByEmail: PersonalDetailsList = {}; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, - callback: (value) => { - personalDetails = value; + callback: (personalDetails) => { + personalDetailsByEmail = lodashMapKeys(personalDetails, (value, key) => value?.login ?? key); }, }); @@ -56,6 +56,11 @@ function createApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork const previousApprovalMode = policy.approvalMode; const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({previousEmployeeList, approvalWorkflow, type: CONST.APPROVAL_WORKFLOW.TYPE.CREATE}); + // If there are no changes to the employees list, we can exit early + if (isEmptyObject(updatedEmployees)) { + return; + } + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -127,6 +132,7 @@ function updateApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork approversToRemove, }); + // If there are no changes to the employees list, we can exit early if (isEmptyObject(updatedEmployees) && !newDefaultApprover) { return; } @@ -258,12 +264,13 @@ function removeApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork API.write(WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); } +/** Set the members of the approval workflow that is currently edited */ function setApprovalWorkflowMembers(members: Member[]) { Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {members, errors: null}); } /** - * Set the approver at the specified index in the current approval workflow + * Set the approver at the specified index in the approval workflow that is currently edited * @param approver - The new approver to set * @param approverIndex - The index of the approver to set * @param policyID - The ID of the policy @@ -280,13 +287,14 @@ function setApprovalWorkflowApprover(approver: Approver, approverIndex: number, // Check if the approver forwards to other approvers and add them to the list if (policy.employeeList[approver.email]?.forwardsTo) { - const personalDetailsByEmail = lodashMapKeys(personalDetails, (value, key) => value?.login ?? key); const additionalApprovers = calculateApprovers({employees: policy.employeeList, firstEmail: approver.email, personalDetailsByEmail}); approvers.splice(approverIndex, approvers.length, ...additionalApprovers); } + // Always clear the additional approver error when an approver is added const errors: Record<string, TranslationPaths | null> = {additionalApprover: null}; - // Check for circular references and reset errors + + // Check for circular references (approver forwards to themselves) and reset other errors const updatedApprovers = approvers.map((existingApprover, index) => { if (!existingApprover) { return; @@ -308,6 +316,7 @@ function setApprovalWorkflowApprover(approver: Approver, approverIndex: number, Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: updatedApprovers, errors}); } +/** Clear one approver at the specified index in the approval workflow that is currently edited */ function clearApprovalWorkflowApprover(approverIndex: number) { if (!currentApprovalWorkflow) { return; @@ -319,6 +328,7 @@ function clearApprovalWorkflowApprover(approverIndex: number) { Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: lodashDropRightWhile(approvers, (approver) => !approver), errors: null}); } +/** Clear all approvers of the approval workflow that is currently edited */ function clearApprovalWorkflowApprovers() { Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: []}); } @@ -333,6 +343,11 @@ function clearApprovalWorkflow() { type ApprovalWorkflowOnyxValidated = Omit<ApprovalWorkflowOnyx, 'approvers'> & {approvers: Approver[]}; +/** + * Validates the approval workflow and sets the errors on the approval workflow + * @param approvalWorkflow the approval workflow to validate + * @returns true if the approval workflow is valid, false otherwise + */ function validateApprovalWorkflow(approvalWorkflow: ApprovalWorkflowOnyx): approvalWorkflow is ApprovalWorkflowOnyxValidated { const errors: Record<string, TranslationPaths> = {}; @@ -355,8 +370,6 @@ function validateApprovalWorkflow(approvalWorkflow: ApprovalWorkflowOnyx): appro } Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {errors}); - - // Return false if there are errors return isEmptyObject(errors); } diff --git a/src/libs/actions/connections/QuickbooksOnline.ts b/src/libs/actions/connections/QuickbooksOnline.ts index d758b6f3b073..94a0bafec547 100644 --- a/src/libs/actions/connections/QuickbooksOnline.ts +++ b/src/libs/actions/connections/QuickbooksOnline.ts @@ -9,9 +9,7 @@ import {getCommandURL} from '@libs/ApiUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ConnectionName, Connections, IntegrationEntityMap} from '@src/types/onyx/Policy'; - -type ConnectionNameExceptNetSuite = Exclude<ConnectionName, typeof CONST.POLICY.CONNECTIONS.NAME.NETSUITE>; +import type {Connections} from '@src/types/onyx/Policy'; function getQuickbooksOnlineSetupLink(policyID: string) { const params: ConnectPolicyToAccountingIntegrationParams = {policyID}; @@ -92,6 +90,7 @@ function buildOnyxDataForQuickbooksConfiguration<TSettingName extends keyof Conn policyID: string, settingName: TSettingName, settingValue: Partial<Connections['quickbooksOnline']['config'][TSettingName]>, + oldSettingValue?: Partial<Connections['quickbooksOnline']['config'][TSettingName]>, ) { const optimisticData: OnyxUpdate[] = [ { @@ -123,7 +122,7 @@ function buildOnyxDataForQuickbooksConfiguration<TSettingName extends keyof Conn connections: { [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: { - [settingName]: settingValue ?? null, + [settingName]: oldSettingValue ?? null, pendingFields: { [settingName]: null, }, @@ -165,13 +164,13 @@ function buildOnyxDataForQuickbooksConfiguration<TSettingName extends keyof Conn }; } -function updateQuickbooksOnlineEnableNewCategories(policyID: string, settingValue: boolean) { - const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICK_BOOKS_CONFIG.ENABLE_NEW_CATEGORIES, settingValue); +function updateQuickbooksOnlineEnableNewCategories<TSettingValue extends Connections['quickbooksOnline']['config']['enableNewCategories']>(policyID: string, settingValue: TSettingValue) { + const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.ENABLE_NEW_CATEGORIES, settingValue, !settingValue); const parameters: UpdateQuickbooksOnlineGenericTypeParams = { policyID, settingValue: JSON.stringify(settingValue), - idempotencyKey: String(CONST.QUICK_BOOKS_CONFIG.ENABLE_NEW_CATEGORIES), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.ENABLE_NEW_CATEGORIES), }; API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_ENABLE_NEW_CATEGORIES, parameters, onyxData); } @@ -187,76 +186,93 @@ function updateQuickbooksOnlineAutoCreateVendor<TConfigUpdate extends Partial<Co policyID, autoCreateVendor: JSON.stringify(configUpdate.autoCreateVendor), nonReimbursableBillDefaultVendor: JSON.stringify(configUpdate.nonReimbursableBillDefaultVendor), - idempotencyKey: CONST.QUICK_BOOKS_CONFIG.AUTO_CREATE_VENDOR, + idempotencyKey: CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR, }; API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_AUTO_CREATE_VENDOR, parameters, onyxData); } -function updateQuickbooksOnlineReimbursableExpensesAccount<TConnectionName extends ConnectionNameExceptNetSuite, TSettingName extends keyof Connections[TConnectionName]['config']>( +function updateQuickbooksOnlineReimbursableExpensesAccount<TSettingValue extends Connections['quickbooksOnline']['config']['reimbursableExpensesAccount']>( policyID: string, - settingValue: Partial<Connections[TConnectionName]['config'][TSettingName]>, + settingValue: TSettingValue, + oldSettingValue: TSettingValue, ) { - const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICK_BOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT, settingValue); + const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT, settingValue, oldSettingValue); const parameters: UpdateQuickbooksOnlineGenericTypeParams = { policyID, settingValue: JSON.stringify(settingValue), - idempotencyKey: String(CONST.QUICK_BOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT), }; API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_REIMBURSABLE_EXPENSES_ACCOUNT, parameters, onyxData); } -function updateQuickbooksOnlineSyncLocations(policyID: string, settingValue: IntegrationEntityMap) { - const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICK_BOOKS_CONFIG.SYNC_LOCATIONS, settingValue); +function updateQuickbooksOnlineSyncLocations<TSettingValue extends Connections['quickbooksOnline']['config']['syncLocations']>( + policyID: string, + settingValue: TSettingValue, + oldSettingValue?: TSettingValue, +) { + const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.SYNC_LOCATIONS, settingValue, oldSettingValue); const parameters: UpdateQuickbooksOnlineGenericTypeParams = { policyID, settingValue: JSON.stringify(settingValue), - idempotencyKey: String(CONST.QUICK_BOOKS_CONFIG.SYNC_LOCATIONS), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.SYNC_LOCATIONS), }; API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_SYNC_LOCATIONS, parameters, onyxData); } -function updateQuickbooksOnlineSyncCustomers(policyID: string, settingValue: IntegrationEntityMap) { - const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICK_BOOKS_CONFIG.SYNC_CUSTOMERS, settingValue); +function updateQuickbooksOnlineSyncCustomers<TSettingValue extends Connections['quickbooksOnline']['config']['syncCustomers']>( + policyID: string, + settingValue: TSettingValue, + oldSettingValue?: TSettingValue, +) { + const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.SYNC_CUSTOMERS, settingValue, oldSettingValue); const parameters: UpdateQuickbooksOnlineGenericTypeParams = { policyID, settingValue: JSON.stringify(settingValue), - idempotencyKey: String(CONST.QUICK_BOOKS_CONFIG.SYNC_CUSTOMERS), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.SYNC_CUSTOMERS), }; API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_SYNC_CUSTOMERS, parameters, onyxData); } -function updateQuickbooksOnlineSyncClasses(policyID: string, settingValue: IntegrationEntityMap) { - const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICK_BOOKS_CONFIG.SYNC_CLASSES, settingValue); +function updateQuickbooksOnlineSyncClasses<TSettingValue extends Connections['quickbooksOnline']['config']['syncClasses']>( + policyID: string, + settingValue: TSettingValue, + oldSettingValue?: TSettingValue, +) { + const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.SYNC_CLASSES, settingValue, oldSettingValue); const parameters: UpdateQuickbooksOnlineGenericTypeParams = { policyID, settingValue: JSON.stringify(settingValue), - idempotencyKey: String(CONST.QUICK_BOOKS_CONFIG.SYNC_CLASSES), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.SYNC_CLASSES), }; API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_SYNC_CLASSES, parameters, onyxData); } -function updateQuickbooksOnlineNonReimbursableBillDefaultVendor(policyID: string, settingValue: string) { - const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR, settingValue); +function updateQuickbooksOnlineNonReimbursableBillDefaultVendor<TSettingValue extends Connections['quickbooksOnline']['config']['nonReimbursableBillDefaultVendor']>( + policyID: string, + settingValue: TSettingValue, + oldSettingValue?: TSettingValue, +) { + const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR, settingValue, oldSettingValue); const parameters: UpdateQuickbooksOnlineGenericTypeParams = { policyID, settingValue: JSON.stringify(settingValue), - idempotencyKey: String(CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR), }; API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_BILL_DEFAULT_VENDOR, parameters, onyxData); } -function updateQuickbooksOnlineSyncTax(policyID: string, settingValue: boolean) { - const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICK_BOOKS_CONFIG.SYNC_TAX, settingValue); +function updateQuickbooksOnlineSyncTax<TSettingValue extends Connections['quickbooksOnline']['config']['syncTax']>(policyID: string, settingValue: TSettingValue) { + const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.SYNC_TAX, settingValue, !settingValue); const parameters: UpdateQuickbooksOnlineGenericTypeParams = { policyID, settingValue: JSON.stringify(settingValue), - idempotencyKey: String(CONST.QUICK_BOOKS_CONFIG.SYNC_TAX), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.SYNC_TAX), }; API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_SYNC_TAX, parameters, onyxData); } diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts index 5a373b302c7d..20581d40ed0b 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -74,7 +74,7 @@ function createErrorFields<TConnectionName extends ConnectionNameExceptNetSuite, }, {}); } -function updatePolicyXeroConnectionConfig<TConnectionName extends ConnectionNameExceptNetSuite, TSettingName extends keyof Connections[TConnectionName]['config']>( +function updatePolicyConnectionConfig<TConnectionName extends ConnectionNameExceptNetSuite, TSettingName extends keyof Connections[TConnectionName]['config']>( policyID: string, connectionName: TConnectionName, settingName: TSettingName, @@ -143,89 +143,6 @@ function updatePolicyXeroConnectionConfig<TConnectionName extends ConnectionName }; API.write(WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG, parameters, {optimisticData, failureData, successData}); } - -function updatePolicyConnectionConfig<TConnectionName extends ConnectionNameExceptNetSuite, TSettingName extends keyof Connections[TConnectionName]['config']>( - policyID: string, - connectionName: TConnectionName, - settingName: TSettingName, - settingValue: Partial<Connections[TConnectionName]['config'][TSettingName]>, -) { - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - connections: { - [connectionName]: { - config: { - [settingName]: settingValue ?? null, - pendingFields: { - [settingName]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, - errorFields: { - [settingName]: null, - }, - }, - }, - }, - }, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - connections: { - [connectionName]: { - config: { - [settingName]: settingValue ?? null, - pendingFields: { - [settingName]: null, - }, - errorFields: { - [settingName]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), - }, - }, - }, - }, - }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - connections: { - [connectionName]: { - config: { - [settingName]: settingValue ?? null, - pendingFields: { - [settingName]: null, - }, - errorFields: { - [settingName]: null, - }, - }, - }, - }, - }, - }, - ]; - - const parameters: UpdatePolicyConnectionConfigParams = { - policyID, - connectionName, - settingName: String(settingName), - settingValue: JSON.stringify(settingValue), - idempotencyKey: String(settingName), - }; - API.write(WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG, parameters, {optimisticData, failureData, successData}); -} - /** * This method returns read command and stage in progres for a given accounting integration. * @@ -465,7 +382,6 @@ function isConnectionInProgress(connectionSyncProgress: OnyxEntry<PolicyConnecti export { removePolicyConnection, updatePolicyConnectionConfig, - updatePolicyXeroConnectionConfig, updateManyPolicyConnectionConfigs, isAuthenticationError, syncConnection, diff --git a/src/libs/fileDownload/index.android.ts b/src/libs/fileDownload/index.android.ts index 83255231d26b..a1e81e47994d 100644 --- a/src/libs/fileDownload/index.android.ts +++ b/src/libs/fileDownload/index.android.ts @@ -111,8 +111,7 @@ const postDownloadFile = (url: string, fileName?: string, formData?: FormData, o }) .then((fileData) => { const finalFileName = FileUtils.appendTimeToFileName(fileName ?? 'Expensify'); - const downloadPath = `${RNFS.DownloadDirectoryPath}/Expensify/${finalFileName}`; - + const downloadPath = `${RNFS.DownloadDirectoryPath}/${finalFileName}`; return RNFS.writeFile(downloadPath, fileData, 'utf8').then(() => downloadPath); }) .then((downloadPath) => diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx index 9922eeb2a430..5ac849247ae0 100644 --- a/src/pages/EditReportFieldPage.tsx +++ b/src/pages/EditReportFieldPage.tsx @@ -6,8 +6,8 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import ConfirmModal from '@components/ConfirmModal'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import type {ThreeDotsMenuItem} from '@components/HeaderWithBackButton/types'; import * as Expensicons from '@components/Icon/Expensicons'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -93,12 +93,12 @@ function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps) const fieldValue = isReportFieldTitle ? report.reportName ?? '' : reportField.value ?? reportField.defaultValue; - const menuItems: ThreeDotsMenuItem[] = []; + const menuItems: PopoverMenuItem[] = []; const isReportFieldDeletable = reportField.deletable && !isReportFieldTitle; if (isReportFieldDeletable) { - menuItems.push({icon: Expensicons.Trashcan, text: translate('common.delete'), onSelected: () => setIsDeleteModalVisible(true)}); + menuItems.push({icon: Expensicons.Trashcan, text: translate('common.delete'), onSelected: () => setIsDeleteModalVisible(true), shouldCallAfterModalHide: true}); } const fieldName = Str.UCFirst(reportField.name); diff --git a/src/pages/ErrorPage/NotFoundPage.tsx b/src/pages/ErrorPage/NotFoundPage.tsx index cfdeab9c51c7..6a63a7204215 100644 --- a/src/pages/ErrorPage/NotFoundPage.tsx +++ b/src/pages/ErrorPage/NotFoundPage.tsx @@ -2,18 +2,37 @@ import React from 'react'; import type {FullPageNotFoundViewProps} from '@components/BlockingViews/FullPageNotFoundView'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ScreenWrapper from '@components/ScreenWrapper'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import Navigation from '@libs/Navigation/Navigation'; +import * as ReportUtils from '@libs/ReportUtils'; type NotFoundPageProps = { onBackButtonPress?: () => void; + isReportRelatedPage?: boolean; } & FullPageNotFoundViewProps; // eslint-disable-next-line rulesdir/no-negated-variables -function NotFoundPage({onBackButtonPress, ...fullPageNotFoundViewProps}: NotFoundPageProps) { +function NotFoundPage({onBackButtonPress = () => Navigation.goBack(), isReportRelatedPage, ...fullPageNotFoundViewProps}: NotFoundPageProps) { + const {isSmallScreenWidth} = useResponsiveLayout(); + return ( <ScreenWrapper testID={NotFoundPage.displayName}> <FullPageNotFoundView shouldShow - onBackButtonPress={onBackButtonPress} + onBackButtonPress={() => { + if (!isReportRelatedPage || !isSmallScreenWidth) { + onBackButtonPress(); + return; + } + const topmostReportId = Navigation.getTopmostReportId(); + const report = ReportUtils.getReport(topmostReportId ?? ''); + // detect the report is invalid + if (topmostReportId && (!report || report.errorFields?.notFound)) { + Navigation.dismissModal(); + return; + } + onBackButtonPress(); + }} // eslint-disable-next-line react/jsx-props-no-spreading {...fullPageNotFoundViewProps} /> diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 7f5ec7ec5fef..0308982f382c 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -1,7 +1,7 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import type {SectionListData} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx, withOnyx} from 'react-native-onyx'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useOptionsList} from '@components/OptionListContextProvider'; @@ -14,6 +14,7 @@ import type {WithNavigationTransitionEndProps} from '@components/withNavigationT import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as UserSearchPhraseActions from '@libs/actions/RoomMembersUserSearchPhrase'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as LoginUtils from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -45,9 +46,14 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen const styles = useThemeStyles(); const {translate} = useLocalize(); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE); + const [searchValue, debouncedSearchTerm, setSearchValue] = useDebouncedState(userSearchPhrase ?? ''); const [selectedOptions, setSelectedOptions] = useState<ReportUtils.OptionData[]>([]); + useEffect(() => { + UserSearchPhraseActions.updateUserSearchPhrase(debouncedSearchTerm); + }, [debouncedSearchTerm]); + // Any existing participants and Expensify emails should not be eligible for invitation const excludedUsers = useMemo( () => [...PersonalDetailsUtils.getLoginsByAccountIDs(ReportUtils.getParticipantsAccountIDsForDisplay(report, false, true)), ...CONST.EXPENSIFY_EMAILS], @@ -98,8 +104,8 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen filterSelectedOptions = selectedOptions.filter((option) => { const accountID = option?.accountID; const isOptionInPersonalDetails = inviteOptions.personalDetails.some((personalDetail) => accountID && personalDetail?.accountID === accountID); - const searchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(debouncedSearchTerm); - const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); + const processedSearchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(debouncedSearchTerm); + const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(processedSearchValue) || !!option.login?.toLowerCase().includes(processedSearchValue); return isPartOfSearchTerm || isOptionInPersonalDetails; }); } @@ -177,22 +183,22 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen }, [selectedOptions, backRoute, reportID, validate]); const headerMessage = useMemo(() => { - const searchValue = debouncedSearchTerm.trim().toLowerCase(); + const processedLogin = debouncedSearchTerm.trim().toLowerCase(); const expensifyEmails = CONST.EXPENSIFY_EMAILS as string[]; - if (!inviteOptions.userToInvite && expensifyEmails.includes(searchValue)) { + if (!inviteOptions.userToInvite && expensifyEmails.includes(processedLogin)) { return translate('messages.errorMessageInvalidEmail'); } if ( !inviteOptions.userToInvite && excludedUsers.includes( - PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible - ? PhoneNumber.addSMSDomainIfPhoneNumber(LoginUtils.appendCountryCode(searchValue)) - : searchValue, + PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(processedLogin)).possible + ? PhoneNumber.addSMSDomainIfPhoneNumber(LoginUtils.appendCountryCode(processedLogin)) + : processedLogin, ) ) { - return translate('messages.userIsAlreadyMember', {login: searchValue, name: reportName ?? ''}); + return translate('messages.userIsAlreadyMember', {login: processedLogin, name: reportName ?? ''}); } - return OptionsListUtils.getHeaderMessage(inviteOptions.recentReports.length + inviteOptions.personalDetails.length !== 0, !!inviteOptions.userToInvite, searchValue); + return OptionsListUtils.getHeaderMessage(inviteOptions.recentReports.length + inviteOptions.personalDetails.length !== 0, !!inviteOptions.userToInvite, processedLogin); }, [debouncedSearchTerm, inviteOptions.userToInvite, inviteOptions.recentReports.length, inviteOptions.personalDetails.length, excludedUsers, translate, reportName]); const footerContent = useMemo( @@ -200,7 +206,10 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen <FormAlertWithSubmitButton isDisabled={!selectedOptions.length} buttonText={translate('common.invite')} - onSubmit={inviteUsers} + onSubmit={() => { + UserSearchPhraseActions.clearUserSearchPhrase(); + inviteUsers(); + }} containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]} enabledWhenOffline /> @@ -227,8 +236,10 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen sections={sections} ListItem={InviteMemberListItem} textInputLabel={translate('selectionList.nameEmailOrPhoneNumber')} - textInputValue={searchTerm} - onChangeText={setSearchTerm} + textInputValue={searchValue} + onChangeText={(value) => { + setSearchValue(value); + }} headerMessage={headerMessage} onSelectRow={toggleOption} onConfirm={inviteUsers} diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index d2dbd7eff953..49052208bfdc 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -6,7 +6,6 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OfflineIndicator from '@components/OfflineIndicator'; -import {useSession} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -15,13 +14,13 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import AccountUtils from '@libs/AccountUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ValidationUtils from '@libs/ValidationUtils'; import * as PersonalDetails from '@userActions/PersonalDetails'; import * as Report from '@userActions/Report'; import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/DisplayNameForm'; @@ -40,7 +39,6 @@ function BaseOnboardingPersonalDetails({ const {shouldUseNarrowLayout, onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); const {inputCallbackRef} = useAutoFocusInput(); const [shouldValidateOnChange, setShouldValidateOnChange] = useState(false); - const {accountID} = useSession(); useEffect(() => { Welcome.setOnboardingErrorMessage(''); @@ -76,14 +74,10 @@ function BaseOnboardingPersonalDetails({ // Only navigate to concierge chat when central pane is visible // Otherwise stay on the chats screen. if (!shouldUseNarrowLayout && !route.params?.backTo) { - if (AccountUtils.isAccountIDOddNumber(accountID ?? 0)) { - Report.navigateToSystemChat(); - } else { - Report.navigateToConciergeChat(); - } + Report.navigateToConciergeChat(); } }, - [onboardingPurposeSelected, onboardingAdminsChatReportID, onboardingPolicyID, shouldUseNarrowLayout, route.params?.backTo, accountID], + [onboardingPurposeSelected, onboardingAdminsChatReportID, onboardingPolicyID, shouldUseNarrowLayout, route.params?.backTo], ); const validate = (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { @@ -131,7 +125,7 @@ function BaseOnboardingPersonalDetails({ <HeaderWithBackButton shouldShowBackButton progressBarPercentage={75} - onBackButtonPress={Navigation.goBack} + onBackButtonPress={OnboardingFlow.goBack} /> <FormProvider style={[styles.flexGrow1, onboardingIsMediumOrLargerScreenWidth && styles.mt5, onboardingIsMediumOrLargerScreenWidth ? styles.mh8 : styles.mh5]} diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 71ace34011b1..dc33d82b2a04 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -49,7 +49,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined; const paddingHorizontal = onboardingIsMediumOrLargerScreenWidth ? styles.ph8 : styles.ph5; - const menuItems: MenuItemProps[] = Object.values(CONST.ONBOARDING_CHOICES).map((choice) => { + const menuItems: MenuItemProps[] = Object.values(CONST.SELECTABLE_ONBOARDING_CHOICES).map((choice) => { const translationKey = `onboarding.purpose.${choice}` as const; return { key: translationKey, diff --git a/src/pages/OnboardingWork/BaseOnboardingWork.tsx b/src/pages/OnboardingWork/BaseOnboardingWork.tsx index 0ee6fec55aa4..f803b4e34a65 100644 --- a/src/pages/OnboardingWork/BaseOnboardingWork.tsx +++ b/src/pages/OnboardingWork/BaseOnboardingWork.tsx @@ -18,14 +18,13 @@ import Navigation from '@libs/Navigation/Navigation'; import * as ValidationUtils from '@libs/ValidationUtils'; import * as Policy from '@userActions/Policy/Policy'; import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/WorkForm'; import type {BaseOnboardingWorkOnyxProps, BaseOnboardingWorkProps} from './types'; -const OPEN_WORK_PAGE_PURPOSES = [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]; - function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected, onboardingPolicyID, route}: BaseOnboardingWorkProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -79,8 +78,8 @@ function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected, o <View style={[styles.h100, styles.defaultModalContainer, shouldUseNativeStyles && styles.pt8]}> <HeaderWithBackButton shouldShowBackButton - progressBarPercentage={OPEN_WORK_PAGE_PURPOSES.includes(onboardingPurposeSelected ?? '') ? 50 : 75} - onBackButtonPress={Navigation.goBack} + progressBarPercentage={onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.MANAGE_TEAM ? 50 : 75} + onBackButtonPress={OnboardingFlow.goBack} /> <FormProvider style={[styles.flexGrow1, onboardingIsMediumOrLargerScreenWidth && styles.mt5, onboardingIsMediumOrLargerScreenWidth ? styles.mh8 : styles.mh5]} @@ -110,7 +109,6 @@ function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected, o shouldSaveDraft maxLength={CONST.TITLE_CHARACTER_LIMIT} spellCheck={false} - autoFocus /> </View> </FormProvider> diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 8b12ae4b63ab..9cda6901fdfe 100755 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -155,10 +155,11 @@ function ProfilePage({route}: ProfilePageProps) { const navigateBackTo = route?.params?.backTo; - const shouldShowNotificationPreference = - !isEmptyObject(report) && !isCurrentUser && !!report.notificationPreference && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const notificationPreferenceValue = ReportUtils.getReportNotificationPreference(report); + + const shouldShowNotificationPreference = !isEmptyObject(report) && !isCurrentUser && notificationPreferenceValue !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const notificationPreference = shouldShowNotificationPreference - ? translate(`notificationPreferencesPage.notificationPreferences.${report.notificationPreference}` as TranslationPaths) + ? translate(`notificationPreferencesPage.notificationPreferences.${notificationPreferenceValue}` as TranslationPaths) : ''; // eslint-disable-next-line rulesdir/prefer-early-return diff --git a/src/pages/ReimbursementAccount/BankAccountStep.tsx b/src/pages/ReimbursementAccount/BankAccountStep.tsx index 69e605878725..387478776980 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.tsx +++ b/src/pages/ReimbursementAccount/BankAccountStep.tsx @@ -2,11 +2,10 @@ import React from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import * as Illustrations from '@components/Icon/Illustrations'; +import LottieAnimations from '@components/LottieAnimations'; import MenuItem from '@components/MenuItem'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -36,6 +35,9 @@ type BankAccountStepOnyxProps = { /** If the plaid button has been disabled */ isPlaidDisabled: OnyxEntry<boolean>; + + /** List of bank accounts */ + bankAccountList: OnyxEntry<OnyxTypes.BankAccountList>; }; type BankAccountStepProps = BankAccountStepOnyxProps & { @@ -69,6 +71,7 @@ function BankAccountStep({ reimbursementAccount, onBackButtonPress, isPlaidDisabled = false, + bankAccountList = {}, }: BankAccountStepProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -80,6 +83,7 @@ function BankAccountStep({ } const plaidDesktopMessage = getPlaidDesktopMessage(); const bankAccountRoute = `${ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute('new', policyID, ROUTES.WORKSPACE_INITIAL.getRoute(policyID))}`; + const personalBankAccounts = Object.keys(bankAccountList).filter((key) => bankAccountList[key].accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); const removeExistingBankAccountDetails = () => { const bankAccountData: Partial<ReimbursementAccountForm> = { @@ -119,34 +123,50 @@ function BankAccountStep({ /> <ScrollView style={styles.flex1}> <Section - icon={Illustrations.MoneyWings} title={translate('workspace.bankAccount.streamlinePayments')} + illustration={LottieAnimations.FastMoney} + subtitle={translate('bankAccount.toGetStarted')} + subtitleMuted + illustrationBackgroundColor={theme.fallbackIconColor} + isCentralPane > - <View style={styles.mv3}> - <Text>{translate('bankAccount.toGetStarted')}</Text> - </View> {!!plaidDesktopMessage && ( - <View style={[styles.mv3, styles.flexRow, styles.justifyContentBetween]}> + <View style={[styles.mt3, styles.flexRow, styles.justifyContentBetween]}> <TextLink onPress={() => Link.openExternalLinkWithToken(bankAccountRoute)}>{translate(plaidDesktopMessage)}</TextLink> </View> )} - <Button - icon={Expensicons.Bank} - iconStyles={[styles.customMarginButtonWithMenuItem]} - text={translate('bankAccount.connectOnlineWithPlaid')} - onPress={() => { - if (!!isPlaidDisabled || !user?.validated) { - return; - } - removeExistingBankAccountDetails(); - BankAccounts.openPlaidView(); - }} - isDisabled={!!isPlaidDisabled || !user?.validated} - style={[styles.mt4]} - shouldShowRightIcon - success - innerStyles={[styles.pr2, styles.pl4, styles.h13]} - /> + {!!personalBankAccounts.length && ( + <View style={[styles.flexRow, styles.mt4, styles.alignItemsCenter, styles.pb1, styles.pt1]}> + <Icon + src={Expensicons.Lightbulb} + fill={theme.icon} + additionalStyles={styles.mr2} + medium + /> + <Text + style={[styles.textLabelSupportingNormal, styles.flex1]} + suppressHighlighting + > + {translate('workspace.bankAccount.connectBankAccountNote')} + </Text> + </View> + )} + <View style={styles.mt3}> + <MenuItem + icon={Expensicons.Bank} + title={translate('bankAccount.connectOnlineWithPlaid')} + disabled={!!isPlaidDisabled || !user?.validated} + onPress={() => { + if (!!isPlaidDisabled || !user?.validated) { + return; + } + removeExistingBankAccountDetails(); + BankAccounts.openPlaidView(); + }} + shouldShowRightIcon + wrapperStyle={[styles.cardMenuItem]} + /> + </View> <View style={styles.mv3}> <MenuItem icon={Expensicons.Connect} @@ -165,13 +185,11 @@ function BankAccountStep({ <View style={[styles.mv0, styles.mh5, styles.flexRow, styles.justifyContentBetween]}> <TextLink href={CONST.PRIVACY_URL}>{translate('common.privacy')}</TextLink> <PressableWithoutFeedback - onPress={() => Link.openExternalLink('https://community.expensify.com/discussion/5677/deep-dive-how-expensify-protects-your-information/')} + onPress={() => Link.openExternalLink('https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security/')} style={[styles.flexRow, styles.alignItemsCenter]} accessibilityLabel={translate('bankAccount.yourDataIsSecure')} > - <TextLink href="https://community.expensify.com/discussion/5677/deep-dive-how-expensify-protects-your-information/"> - {translate('bankAccount.yourDataIsSecure')} - </TextLink> + <TextLink href="https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security/">{translate('bankAccount.yourDataIsSecure')}</TextLink> <View style={styles.ml1}> <Icon src={Expensicons.Lock} @@ -195,4 +213,7 @@ export default withOnyx<BankAccountStepProps, BankAccountStepOnyxProps>({ isPlaidDisabled: { key: ONYXKEYS.IS_PLAID_DISABLED, }, + bankAccountList: { + key: ONYXKEYS.BANK_ACCOUNT_LIST, + }, })(BankAccountStep); diff --git a/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx b/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx index a2d1f245152f..ed360dc68842 100644 --- a/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx +++ b/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; @@ -12,6 +12,7 @@ import type {SubStepProps} from '@hooks/useSubStep/types'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDefaultCompanyWebsite} from '@libs/BankAccountUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; +import * as BankAccounts from '@userActions/BankAccounts'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; @@ -58,6 +59,10 @@ function WebsiteBusiness({reimbursementAccount, user, session, onNext, isEditing shouldSaveDraft: isEditing, }); + useEffect(() => { + BankAccounts.addBusinessWebsiteForDraft(defaultCompanyWebsite); + }, [defaultCompanyWebsite]); + return ( <FormProvider formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM} diff --git a/src/pages/ReimbursementAccount/EnableBankAccount/EnableBankAccount.tsx b/src/pages/ReimbursementAccount/EnableBankAccount/EnableBankAccount.tsx index f5d2cf5adb0c..1d8ce04d7f71 100644 --- a/src/pages/ReimbursementAccount/EnableBankAccount/EnableBankAccount.tsx +++ b/src/pages/ReimbursementAccount/EnableBankAccount/EnableBankAccount.tsx @@ -31,7 +31,8 @@ function EnableBankAccount({reimbursementAccount, onBackButtonPress}: EnableBank const {translate} = useLocalize(); const achData = reimbursementAccount?.achData ?? {}; - const {icon, iconSize} = getBankIcon({bankName: achData.bankName, styles}); + const {icon, iconSize, iconStyles} = getBankIcon({bankName: achData.bankName, styles}); + const formattedBankAccountNumber = achData.accountNumber ? `${translate('bankAccount.accountEnding')} ${achData.accountNumber.slice(-4)}` : ''; const bankAccountOwnerName = achData.addressName; const errors = reimbursementAccount?.errors ?? {}; @@ -66,11 +67,12 @@ function EnableBankAccount({reimbursementAccount, onBackButtonPress}: EnableBank title={bankAccountOwnerName} description={formattedBankAccountNumber} icon={icon} + iconStyles={iconStyles} iconWidth={iconSize} iconHeight={iconSize} interactive={false} displayInDefaultIconColor - wrapperStyle={[styles.cardMenuItem, styles.mv3]} + wrapperStyle={[styles.ph0, styles.mv3, styles.h13]} /> <Text style={[styles.mv3]}>{translate('workspace.bankAccount.accountDescriptionWithCards')}</Text> <MenuItem diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index de93ed7a3ced..4c26a6e5c38b 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -7,6 +7,7 @@ import {useOnyx, withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import DelegateNoAccessModal from '@components/DelegateNoAccessModal'; import DisplayNames from '@components/DisplayNames'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -22,6 +23,7 @@ import RoomHeaderAvatars from '@components/RoomHeaderAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useDelegateUserDetails from '@hooks/useDelegateUserDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; @@ -234,19 +236,23 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD const [moneyRequestReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${moneyRequestReport?.reportID}`); const isMoneyRequestExported = ReportUtils.isExported(moneyRequestReportActions); + const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); + const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const unapproveExpenseReportOrShowModal = useCallback(() => { - if (isMoneyRequestExported) { + if (isDelegateAccessRestricted) { + setIsNoDelegateAccessMenuVisible(true); + } else if (isMoneyRequestExported) { setIsUnapproveModalVisible(true); return; } Navigation.dismissModal(); IOU.unapproveExpenseReport(moneyRequestReport); - }, [isMoneyRequestExported, moneyRequestReport]); + }, [isMoneyRequestExported, moneyRequestReport, isDelegateAccessRestricted]); const shouldShowLeaveButton = ReportUtils.canLeaveChat(report, policy); - const reportName = ReportUtils.isDeprecatedGroupDM(report) || isGroupChat ? ReportUtils.getGroupChatName(undefined, false, report) : ReportUtils.getReportName(report); + const reportName = ReportUtils.getReportName(report); const additionalRoomDetails = (isPolicyExpenseChat && !!report?.isOwnPolicyExpenseChat) || ReportUtils.isExpenseReport(report) || isPolicyExpenseChat || isInvoiceRoom @@ -262,7 +268,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD roomDescription = translate('newRoomPage.roomName'); } - const shouldShowNotificationPref = !isMoneyRequestReport && report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const shouldShowNotificationPref = !isMoneyRequestReport && ReportUtils.getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const shouldShowWriteCapability = !isMoneyRequestReport; const shouldShowMenuItem = shouldShowNotificationPref || shouldShowWriteCapability || (!!report?.visibility && report.chatType !== CONST.REPORT.CHAT_TYPE.INVOICE); @@ -552,6 +558,8 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD isTextHold: canHoldUnholdReportAction.canHoldRequest, reportAction: moneyRequestAction, reportID: transactionThreadReportID ? report.reportID : moneyRequestAction?.childReportID ?? '-1', + isDelegateAccessRestricted, + setIsNoDelegateAccessMenuVisible, }), ); } @@ -563,7 +571,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD result.push(PromotedActions.share(report)); return result; - }, [report, moneyRequestAction, canJoin, isExpenseReport, shouldShowHoldAction, canHoldUnholdReportAction.canHoldRequest, transactionThreadReportID]); + }, [report, moneyRequestAction, canJoin, isExpenseReport, shouldShowHoldAction, canHoldUnholdReportAction.canHoldRequest, transactionThreadReportID, isDelegateAccessRestricted]); const nameSectionExpenseIOU = ( <View style={[styles.reportDetailsRoomInfo, styles.mw100]}> @@ -697,7 +705,8 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD if (ReportActionsUtils.isTrackExpenseAction(requestParentReportAction)) { navigateBackToAfterDelete.current = IOU.deleteTrackExpense(moneyRequestReport?.reportID ?? '', iouTransactionID, requestParentReportAction, isSingleTransactionView); } else { - navigateBackToAfterDelete.current = IOU.deleteMoneyRequest(iouTransactionID, requestParentReportAction, isSingleTransactionView); + const {urlToNavigateBack} = IOU.prepareToCleanUpMoneyRequest(iouTransactionID, requestParentReportAction, true); + navigateBackToAfterDelete.current = urlToNavigateBack; } isTransactionDeleted.current = true; @@ -805,6 +814,12 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD Navigation.dismissModal(); } else { ReportUtils.navigateBackAfterDeleteTransaction(navigateBackToAfterDelete.current, true); + if (!requestParentReportAction) { + return; + } + setTimeout(() => { + IOU.deleteMoneyRequest(iouTransactionID, requestParentReportAction, isSingleTransactionView); + }, CONST.ANIMATED_TRANSITION); } }} prompt={caseID === CASES.DEFAULT ? translate('task.deleteConfirmation') : translate('iou.deleteConfirmation')} @@ -813,6 +828,11 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD danger shouldEnableNewFocusManagement /> + <DelegateNoAccessModal + isNoDelegateAccessMenuVisible={isNoDelegateAccessMenuVisible} + onClose={() => setIsNoDelegateAccessMenuVisible(false)} + delegatorEmail={delegatorEmail ?? ''} + /> <ConfirmModal title={translate('iou.unapproveReport')} isVisible={isUnapproveModalVisible} diff --git a/src/pages/ReportParticipantDetailsPage.tsx b/src/pages/ReportParticipantDetailsPage.tsx index f03b2475d5a7..1975b201a958 100644 --- a/src/pages/ReportParticipantDetailsPage.tsx +++ b/src/pages/ReportParticipantDetailsPage.tsx @@ -18,6 +18,7 @@ import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Report from '@libs/actions/Report'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; import type {ParticipantsNavigatorParamList} from '@navigation/types'; @@ -53,7 +54,7 @@ function ReportParticipantDetails({personalDetails, report, route}: ReportPartic const member = report?.participants?.[accountID]; const details = personalDetails?.[accountID] ?? ({} as PersonalDetails); const fallbackIcon = details.fallbackIcon ?? ''; - const displayName = details.displayName ?? ''; + const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(details); const isCurrentUserAdmin = ReportUtils.isGroupChatAdmin(report, currentUserPersonalDetails?.accountID); const isSelectedMemberCurrentUser = accountID === currentUserPersonalDetails?.accountID; const removeUser = useCallback(() => { @@ -91,7 +92,7 @@ function ReportParticipantDetails({personalDetails, report, route}: ReportPartic size={CONST.AVATAR_SIZE.XLARGE} fallbackIcon={fallbackIcon} /> - {!!(details.displayName ?? '') && ( + {!!(displayName ?? '') && ( <Text style={[styles.textHeadline, styles.pre, styles.mb6, styles.w100, styles.textAlignCenter]} numberOfLines={1} @@ -102,7 +103,7 @@ function ReportParticipantDetails({personalDetails, report, route}: ReportPartic {isCurrentUserAdmin && ( <> <Button - text={translate('workspace.people.removeMemberGroupButtonTitle')} + text={translate('workspace.people.removeGroupMemberButtonTitle')} onPress={() => setIsRemoveMemberConfirmModalVisible(true)} medium isDisabled={isSelectedMemberCurrentUser} @@ -112,7 +113,7 @@ function ReportParticipantDetails({personalDetails, report, route}: ReportPartic /> <ConfirmModal danger - title={translate('workspace.people.removeMemberGroupButtonTitle')} + title={translate('workspace.people.removeGroupMemberButtonTitle')} isVisible={isRemoveMemberConfirmModalVisible} onConfirm={removeUser} onCancel={() => setIsRemoveMemberConfirmModalVisible(false)} diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index 62f2101ce1ba..c92c6fc2a901 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -17,14 +17,18 @@ import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import Text from '@components/Text'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import * as Report from '@libs/actions/Report'; +import * as UserSearchPhraseActions from '@libs/actions/RoomMembersUserSearchPhrase'; import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -44,6 +48,7 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const selectionListRef = useRef<SelectionListHandle>(null); const textInputRef = useRef<TextInput>(null); + const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); const {selectionMode} = useMobileSelectionMode(); const [session] = useOnyx(ONYXKEYS.SESSION); @@ -52,7 +57,20 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { const isCurrentUserAdmin = ReportUtils.isGroupChatAdmin(report, currentUserAccountID); const isGroupChat = useMemo(() => ReportUtils.isGroupChat(report), [report]); const isFocused = useIsFocused(); + const {isOffline} = useNetwork(); const canSelectMultiple = isGroupChat && isCurrentUserAdmin && (isSmallScreenWidth ? selectionMode?.isEnabled : true); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + + useEffect( + () => () => { + UserSearchPhraseActions.clearUserSearchPhrase(); + }, + [], + ); + + useEffect(() => { + UserSearchPhraseActions.updateUserSearchPhrase(debouncedSearchValue); + }, [debouncedSearchValue]); useEffect(() => { if (isFocused) { @@ -61,14 +79,49 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { setSelectedMembers([]); }, [isFocused]); - const getUsers = useCallback((): MemberOption[] => { + const chatParticipants = ReportUtils.getParticipantsList(report, personalDetails); + + const pendingChatMembers = report?.pendingChatMembers; + const reportParticipants = report?.participants; + + // Get the active chat members by filtering out the pending members with delete action + const activeParticipants = chatParticipants.filter((accountID) => { + const pendingMember = pendingChatMembers?.findLast((member) => member.accountID === accountID.toString()); + if (!personalDetails?.[accountID]) { + return false; + } + // When offline, we want to include the pending members with delete action as they are displayed in the list as well + return !pendingMember || isOffline || pendingMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + }); + + // Include the search bar when there are 8 or more active members in the selection list + const shouldShowTextInput = activeParticipants.length >= CONST.SHOULD_SHOW_MEMBERS_SEARCH_INPUT_BREAKPOINT; + + useEffect(() => { + if (!isFocused) { + return; + } + if (shouldShowTextInput) { + setSearchValue(userSearchPhrase ?? ''); + } else { + UserSearchPhraseActions.clearUserSearchPhrase(); + setSearchValue(''); + } + }, [isFocused, setSearchValue, shouldShowTextInput, userSearchPhrase]); + + const getParticipants = () => { let result: MemberOption[] = []; - const chatParticipants = ReportUtils.getParticipantsList(report, personalDetails); + chatParticipants.forEach((accountID) => { - const role = report.participants?.[accountID].role; + const role = reportParticipants?.[accountID].role; const details = personalDetails?.[accountID]; - const pendingChatMember = report?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString()); + // If search value is provided, filter out members that don't match the search value + if (!details || (searchValue.trim() && !OptionsListUtils.isSearchStringMatchUserDetails(details, searchValue))) { + return; + } + + const pendingChatMember = pendingChatMembers?.findLast((member) => member.accountID === accountID.toString()); const isSelected = selectedMembers.includes(accountID) && canSelectMultiple; const isAdmin = role === CONST.REPORT.ROLE.ADMIN; let roleBadge = null; @@ -76,7 +129,7 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { roleBadge = <Badge text={translate('common.admin')} />; } - const pendingAction = pendingChatMember?.pendingAction ?? report.participants?.[accountID]?.pendingAction; + const pendingAction = pendingChatMember?.pendingAction ?? reportParticipants?.[accountID]?.pendingAction; result.push({ keyForList: `${accountID}`, @@ -101,9 +154,9 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { result = result.sort((a, b) => (a.text ?? '').toLowerCase().localeCompare((b.text ?? '').toLowerCase())); return result; - }, [formatPhoneNumber, personalDetails, report, selectedMembers, currentUserAccountID, translate, canSelectMultiple]); + }; - const participants = useMemo(() => getUsers(), [getUsers]); + const participants = getParticipants(); /** * Add user from the selectedMembers list @@ -150,6 +203,8 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { // Remove the admin from the list const accountIDsToRemove = selectedMembers.filter((id) => id !== currentUserAccountID); Report.removeFromGroupChat(report.reportID, accountIDsToRemove); + setSearchValue(''); + UserSearchPhraseActions.clearUserSearchPhrase(); setSelectedMembers([]); setRemoveMembersConfirmModalVisible(false); }; @@ -182,27 +237,17 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { [selectedMembers, addUser, removeUser, currentUserAccountID], ); - const headerContent = useMemo(() => { - if (!isGroupChat) { - return; - } - - return <Text style={[styles.pl5, styles.mb4, styles.mt6, styles.textSupporting]}>{translate('groupChat.groupMembersListTitle')}</Text>; - }, [styles, translate, isGroupChat]); - const customListHeader = useMemo(() => { - if (!isGroupChat) { - return; - } - const header = ( <View style={[styles.flex1, styles.flexRow, styles.justifyContentBetween]}> <View> - <Text style={[styles.searchInputStyle, isCurrentUserAdmin ? styles.ml3 : styles.ml0]}>{translate('common.member')}</Text> - </View> - <View style={[StyleUtils.getMinimumWidth(60)]}> - <Text style={[styles.searchInputStyle, styles.textAlignCenter]}>{translate('common.role')}</Text> + <Text style={[styles.searchInputStyle, canSelectMultiple ? styles.ml3 : styles.ml0]}>{translate('common.member')}</Text> </View> + {isGroupChat && ( + <View style={[StyleUtils.getMinimumWidth(60)]}> + <Text style={[styles.searchInputStyle, styles.textAlignCenter]}>{translate('common.role')}</Text> + </View> + )} </View> ); @@ -210,8 +255,8 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { return header; } - return <View style={[styles.peopleRow, styles.userSelectNone, styles.ph9, styles.pb5]}>{header}</View>; - }, [styles, translate, isGroupChat, isCurrentUserAdmin, StyleUtils, canSelectMultiple]); + return <View style={[styles.peopleRow, styles.userSelectNone, styles.ph9, styles.pb5, shouldShowTextInput ? styles.mt3 : styles.mt0]}>{header}</View>; + }, [styles, translate, isGroupChat, shouldShowTextInput, StyleUtils, canSelectMultiple]); const bulkActionsButtonOptions = useMemo(() => { const options: Array<DropdownOption<WorkspaceMemberBulkActionType>> = [ @@ -262,6 +307,7 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { customText={translate('workspace.common.selected', {selectedNumber: selectedMembers.length})} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} onPress={() => null} + isSplitButton={false} options={bulkActionsButtonOptions} style={[shouldUseNarrowLayout && styles.flexGrow1]} isDisabled={!selectedMembers.length} @@ -308,6 +354,12 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { const selectionModeHeader = selectionMode?.isEnabled && isSmallScreenWidth; + // eslint-disable-next-line rulesdir/no-negated-variables + const memberNotFoundMessage = isGroupChat + ? `${translate('roomMembersPage.memberNotFound')} ${translate('roomMembersPage.useInviteButton')}` + : translate('roomMembersPage.memberNotFound'); + const headerMessage = searchValue.trim() && !participants.length ? memberNotFoundMessage : ''; + return ( <ScreenWrapper includeSafeAreaPaddingBottom={false} @@ -325,6 +377,7 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { } if (report) { + setSearchValue(''); Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); } }} @@ -349,15 +402,21 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { }); }} /> - <View style={[styles.w100, styles.flex1]}> + <View style={[styles.w100, isGroupChat ? styles.mt3 : styles.mt0, styles.flex1]}> <SelectionListWithModal ref={selectionListRef} canSelectMultiple={canSelectMultiple} turnOnSelectionModeOnLongPress={isCurrentUserAdmin && isGroupChat} onTurnOnSelectionMode={(item) => item && toggleUser(item)} sections={[{data: participants}]} + shouldShowTextInput={shouldShowTextInput} + textInputLabel={translate('selectionList.findMember')} + textInputValue={searchValue} + onChangeText={(value) => { + setSearchValue(value); + }} + headerMessage={headerMessage} ListItem={TableListItem} - headerContent={headerContent} onSelectRow={openMemberDetails} shouldSingleExecuteRowSelect={!(isGroupChat && isCurrentUserAdmin)} onCheckboxPress={(item) => toggleUser(item)} @@ -365,7 +424,7 @@ function ReportParticipantsPage({report}: WithReportOrNotFoundProps) { showScrollIndicator textInputRef={textInputRef} customListHeader={customListHeader} - listHeaderWrapperStyle={[styles.ph9]} + listHeaderWrapperStyle={[styles.ph9, styles.mt3]} /> </View> </FullPageNotFoundView> diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index 17239e6d4fb5..0384cb947b2f 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -10,25 +10,26 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useOptionsList} from '@components/OptionListContextProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import InviteMemberListItem from '@components/SelectionList/InviteMemberListItem'; import type {Section} from '@components/SelectionList/types'; -import UserListItem from '@components/SelectionList/UserListItem'; import withNavigationTransitionEnd from '@components/withNavigationTransitionEnd'; import type {WithNavigationTransitionEndProps} from '@components/withNavigationTransitionEnd'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportActions from '@libs/actions/Report'; +import * as UserSearchPhraseActions from '@libs/actions/RoomMembersUserSearchPhrase'; import {READ_COMMANDS} from '@libs/API/types'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import HttpUtils from '@libs/HttpUtils'; import * as LoginUtils from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; +import type {RoomMembersNavigatorParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import type {RoomInviteNavigatorParamList} from '@navigation/types'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -38,9 +39,8 @@ import type {Policy} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; import withReportOrNotFound from './home/report/withReportOrNotFound'; -import SearchInputManager from './workspace/SearchInputManager'; -type RoomInvitePageProps = WithReportOrNotFoundProps & WithNavigationTransitionEndProps & StackScreenProps<RoomInviteNavigatorParamList, typeof SCREENS.ROOM_INVITE_ROOT>; +type RoomInvitePageProps = WithReportOrNotFoundProps & WithNavigationTransitionEndProps & StackScreenProps<RoomMembersNavigatorParamList, typeof SCREENS.ROOM_MEMBERS.INVITE>; type Sections = Array<SectionListData<OptionsListUtils.MemberForList, Section<OptionsListUtils.MemberForList>>>; function RoomInvitePage({ @@ -53,20 +53,17 @@ function RoomInvitePage({ }: RoomInvitePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(userSearchPhrase ?? ''); const [selectedOptions, setSelectedOptions] = useState<ReportUtils.OptionData[]>([]); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); - const {options, areOptionsInitialized} = useOptionsList(); - useEffect(() => { - setSearchTerm(SearchInputManager.searchInput); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, []); + const {options, areOptionsInitialized} = useOptionsList(); // Any existing participants and Expensify emails should not be eligible for invitation const excludedUsers = useMemo(() => { const visibleParticipantAccountIDs = Object.entries(report.participants ?? {}) - .filter(([, participant]) => participant && !participant.hidden) + .filter(([, participant]) => participant && participant.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) .map(([accountID]) => Number(accountID)); return [...PersonalDetailsUtils.getLoginsByAccountIDs(visibleParticipantAccountIDs), ...CONST.EXPENSIFY_EMAILS].map((participant) => PhoneNumber.addSMSDomainIfPhoneNumber(participant), @@ -207,7 +204,7 @@ function RoomInvitePage({ if (reportID) { Report.inviteToRoom(reportID, invitedEmailsToAccountIDs); } - SearchInputManager.searchInput = ''; + UserSearchPhraseActions.clearUserSearchPhrase(); Navigation.navigate(backRoute); }, [selectedOptions, backRoute, reportID, validate]); @@ -239,6 +236,7 @@ function RoomInvitePage({ }, [debouncedSearchTerm, inviteOptions.userToInvite, inviteOptions.personalDetails, excludedUsers, translate, reportName]); useEffect(() => { + UserSearchPhraseActions.updateUserSearchPhrase(debouncedSearchTerm); ReportActions.searchInServer(debouncedSearchTerm); }, [debouncedSearchTerm]); @@ -261,11 +259,10 @@ function RoomInvitePage({ <SelectionList canSelectMultiple sections={sections} - ListItem={UserListItem} + ListItem={InviteMemberListItem} textInputLabel={translate('selectionList.nameEmailOrPhoneNumber')} textInputValue={searchTerm} onChangeText={(value) => { - SearchInputManager.searchInput = value; setSearchTerm(value); }} headerMessage={headerMessage} diff --git a/src/pages/RoomMemberDetailsPage.tsx b/src/pages/RoomMemberDetailsPage.tsx new file mode 100644 index 000000000000..5f982595e6ff --- /dev/null +++ b/src/pages/RoomMemberDetailsPage.tsx @@ -0,0 +1,124 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Avatar from '@components/Avatar'; +import Button from '@components/Button'; +import ConfirmModal from '@components/ConfirmModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as Report from '@libs/actions/Report'; +import type {RoomMembersNavigatorParamList} from '@libs/Navigation/types'; +import Navigation from '@navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetails} from '@src/types/onyx'; +import NotFoundPage from './ErrorPage/NotFoundPage'; +import withReportOrNotFound from './home/report/withReportOrNotFound'; +import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; + +type RoomMemberDetailsPagePageProps = WithReportOrNotFoundProps & StackScreenProps<RoomMembersNavigatorParamList, typeof SCREENS.ROOM_MEMBERS.DETAILS>; + +function RoomMemberDetailsPage({report, route}: RoomMemberDetailsPagePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const StyleUtils = useStyleUtils(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + + const [isRemoveMemberConfirmModalVisible, setIsRemoveMemberConfirmModalVisible] = React.useState(false); + + const accountID = Number(route.params.accountID); + const backTo = ROUTES.ROOM_MEMBERS.getRoute(report?.reportID ?? '-1'); + + const member = report?.participants?.[accountID]; + const details = personalDetails?.[accountID] ?? ({} as PersonalDetails); + const fallbackIcon = details.fallbackIcon ?? ''; + const displayName = details.displayName ?? ''; + const isSelectedMemberCurrentUser = accountID === currentUserPersonalDetails?.accountID; + const removeUser = useCallback(() => { + setIsRemoveMemberConfirmModalVisible(false); + Report.removeFromRoom(report?.reportID, [accountID]); + Navigation.goBack(backTo); + }, [backTo, report, accountID]); + + const navigateToProfile = useCallback(() => { + Navigation.navigate(ROUTES.PROFILE.getRoute(accountID, Navigation.getActiveRoute())); + }, [accountID]); + + if (!member) { + return <NotFoundPage />; + } + + return ( + <ScreenWrapper testID={RoomMemberDetailsPage.displayName}> + <HeaderWithBackButton + title={displayName} + onBackButtonPress={() => Navigation.goBack(backTo)} + /> + <View style={[styles.containerWithSpaceBetween, styles.pointerEventsBoxNone, styles.justifyContentStart]}> + <View style={[styles.avatarSectionWrapper, styles.pb0]}> + <Avatar + containerStyles={[styles.avatarXLarge, styles.mv5, styles.noOutline]} + imageStyles={[styles.avatarXLarge]} + source={details.avatar} + avatarID={accountID} + type={CONST.ICON_TYPE_AVATAR} + size={CONST.AVATAR_SIZE.XLARGE} + fallbackIcon={fallbackIcon} + /> + {!!(details.displayName ?? '') && ( + <Text + style={[styles.textHeadline, styles.pre, styles.mb6, styles.w100, styles.textAlignCenter]} + numberOfLines={1} + > + {displayName} + </Text> + )} + <> + <Button + text={translate('workspace.people.removeRoomMemberButtonTitle')} + onPress={() => setIsRemoveMemberConfirmModalVisible(true)} + medium + isDisabled={isSelectedMemberCurrentUser} + icon={Expensicons.RemoveMembers} + iconStyles={StyleUtils.getTransformScaleStyle(0.8)} + style={styles.mv5} + /> + <ConfirmModal + danger + title={translate('workspace.people.removeRoomMemberButtonTitle')} + isVisible={isRemoveMemberConfirmModalVisible} + onConfirm={removeUser} + onCancel={() => setIsRemoveMemberConfirmModalVisible(false)} + prompt={translate('workspace.people.removeMemberPrompt', {memberName: displayName})} + confirmText={translate('common.remove')} + cancelText={translate('common.cancel')} + /> + </> + </View> + <View style={styles.w100}> + <MenuItem + title={translate('common.profile')} + icon={Expensicons.Info} + onPress={navigateToProfile} + shouldShowRightIcon + /> + </View> + </View> + </ScreenWrapper> + ); +} + +RoomMemberDetailsPage.displayName = 'RoomMemberDetailsPage'; + +export default withReportOrNotFound()(RoomMemberDetailsPage); diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index 886ebabe9885..f68fc0bfcfba 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -2,22 +2,30 @@ import {useIsFocused} from '@react-navigation/native'; import type {StackScreenProps} from '@react-navigation/stack'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx, withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Button from '@components/Button'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption, RoomMemberBulkActionType} from '@components/ButtonWithDropdownMenu/types'; import ConfirmModal from '@components/ConfirmModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {FallbackAvatar} from '@components/Icon/Expensicons'; +import * as Expensicons from '@components/Icon/Expensicons'; import {usePersonalDetails} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; +import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; -import UserListItem from '@components/SelectionList/UserListItem'; +import SelectionListWithModal from '@components/SelectionListWithModal'; +import Text from '@components/Text'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import * as UserSearchPhraseActions from '@libs/actions/RoomMembersUserSearchPhrase'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import localeCompare from '@libs/LocaleCompare'; import Navigation from '@libs/Navigation/Navigation'; @@ -36,7 +44,6 @@ import type {Session} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; import withReportOrNotFound from './home/report/withReportOrNotFound'; -import SearchInputManager from './workspace/SearchInputManager'; type RoomMembersPageOnyxProps = { session: OnyxEntry<Session>; @@ -45,31 +52,41 @@ type RoomMembersPageOnyxProps = { type RoomMembersPageProps = WithReportOrNotFoundProps & WithCurrentUserPersonalDetailsProps & RoomMembersPageOnyxProps & - StackScreenProps<RoomMembersNavigatorParamList, typeof SCREENS.ROOM_MEMBERS_ROOT>; + StackScreenProps<RoomMembersNavigatorParamList, typeof SCREENS.ROOM_MEMBERS.ROOT>; function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { const styles = useThemeStyles(); const {formatPhoneNumber, translate} = useLocalize(); const [selectedMembers, setSelectedMembers] = useState<number[]>([]); const [removeMembersConfirmModalVisible, setRemoveMembersConfirmModalVisible] = useState(false); - const [searchValue, setSearchValue] = useState(''); + const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE); + const [searchValue, debouncedSearchTerm, setSearchValue] = useDebouncedState(''); const [didLoadRoomMembers, setDidLoadRoomMembers] = useState(false); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? ''}`], [policies, report?.policyID]); const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(report), [report]); const isFocusedScreen = useIsFocused(); + const {isOffline} = useNetwork(); + + const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); + const canSelectMultiple = isSmallScreenWidth ? selectionMode?.isEnabled : true; useEffect(() => { - setSearchValue(SearchInputManager.searchInput); - }, [isFocusedScreen]); + setSearchValue(userSearchPhrase ?? ''); + }, [isFocusedScreen, setSearchValue, userSearchPhrase]); - useEffect( - () => () => { - SearchInputManager.searchInput = ''; - }, - [], - ); + useEffect(() => { + UserSearchPhraseActions.updateUserSearchPhrase(debouncedSearchTerm); + }, [debouncedSearchTerm]); + + useEffect(() => { + if (isFocusedScreen) { + return; + } + setSelectedMembers([]); + }, [isFocusedScreen]); /** * Get members for the current room @@ -83,6 +100,7 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { }, [report]); useEffect(() => { + UserSearchPhraseActions.clearUserSearchPhrase(); getRoomMembers(); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); @@ -90,13 +108,13 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { /** * Open the modal to invite a user */ - const inviteUser = () => { + const inviteUser = useCallback(() => { if (!report) { return; } setSearchValue(''); Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(report.reportID)); - }; + }, [report, setSearchValue]); /** * Remove selected users from the room @@ -106,6 +124,8 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { if (report) { Report.removeFromRoom(report.reportID, selectedMembers); } + setSearchValue(''); + UserSearchPhraseActions.clearUserSearchPhrase(); setSelectedMembers([]); setRemoveMembersConfirmModalVisible(false); }; @@ -159,17 +179,36 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { } }; - /** - * Show the modal to confirm removal of the selected members - */ - const askForConfirmationToRemove = () => { - setRemoveMembersConfirmModalVisible(true); - }; + const participants = useMemo(() => ReportUtils.getParticipantsList(report, personalDetails, true), [report, personalDetails]); - const getMemberOptions = (): ListItem[] => { - let result: ListItem[] = []; + /** Include the search bar when there are 8 or more active members in the selection list */ + const shouldShowTextInput = useMemo(() => { + // Get the active chat members by filtering out the pending members with delete action + const activeParticipants = participants.filter((accountID) => { + const pendingMember = report?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString()); + if (!personalDetails?.[accountID]) { + return false; + } + // When offline, we want to include the pending members with delete action as they are displayed in the list as well + return !pendingMember || isOffline || pendingMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + }); + return activeParticipants.length >= CONST.SHOULD_SHOW_MEMBERS_SEARCH_INPUT_BREAKPOINT; + }, [participants, personalDetails, isOffline, report]); + + useEffect(() => { + if (!isFocusedScreen) { + return; + } + if (shouldShowTextInput) { + setSearchValue(userSearchPhrase ?? ''); + } else { + UserSearchPhraseActions.clearUserSearchPhrase(); + setSearchValue(''); + } + }, [isFocusedScreen, setSearchValue, shouldShowTextInput, userSearchPhrase]); - const participants = ReportUtils.getParticipantsList(report, personalDetails, true); + const data = useMemo((): ListItem[] => { + let result: ListItem[] = []; participants.forEach((accountID) => { const details = personalDetails[accountID]; @@ -195,7 +234,7 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { alternateText: details?.login ? formatPhoneNumber(details.login) : '', icons: [ { - source: details.avatar ?? FallbackAvatar, + source: details.avatar ?? Expensicons.FallbackAvatar, name: details.login ?? '', type: CONST.ICON_TYPE_AVATAR, id: accountID, @@ -209,7 +248,7 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { result = result.sort((value1, value2) => localeCompare(value1.text ?? '', value2.text ?? '')); return result; - }; + }, [formatPhoneNumber, isPolicyExpenseChat, participants, personalDetails, policy, report.ownerAccountID, report?.pendingChatMembers, searchValue, selectedMembers, session?.accountID]); const dismissError = useCallback( (item: ListItem) => { @@ -224,8 +263,76 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { } return PolicyUtils.isPolicyEmployee(report.policyID, policies); }, [report?.policyID, policies]); - const data = getMemberOptions(); - const headerMessage = searchValue.trim() && !data.length ? translate('roomMembersPage.memberNotFound') : ''; + + const headerMessage = searchValue.trim() && !data.length ? `${translate('roomMembersPage.memberNotFound')} ${translate('roomMembersPage.useInviteButton')}` : ''; + + const bulkActionsButtonOptions = useMemo(() => { + const options: Array<DropdownOption<RoomMemberBulkActionType>> = [ + { + text: translate('workspace.people.removeMembersTitle'), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.REMOVE, + icon: Expensicons.RemoveMembers, + onSelected: () => setRemoveMembersConfirmModalVisible(true), + }, + ]; + return options; + }, [translate, setRemoveMembersConfirmModalVisible]); + + const headerButtons = useMemo(() => { + return ( + <View style={styles.w100}> + {(isSmallScreenWidth ? canSelectMultiple : selectedMembers.length > 0) ? ( + <ButtonWithDropdownMenu<RoomMemberBulkActionType> + shouldAlwaysShowDropdownMenu + pressOnEnter + customText={translate('workspace.common.selected', {selectedNumber: selectedMembers.length})} + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + onPress={() => null} + options={bulkActionsButtonOptions} + isSplitButton={false} + style={[shouldUseNarrowLayout && styles.flexGrow1]} + isDisabled={!selectedMembers.length} + /> + ) : ( + <Button + medium + success + onPress={inviteUser} + text={translate('workspace.invite.member')} + icon={Expensicons.Plus} + innerStyles={[shouldUseNarrowLayout && styles.alignItemsCenter]} + style={[shouldUseNarrowLayout && styles.flexGrow1]} + /> + )} + </View> + ); + }, [bulkActionsButtonOptions, inviteUser, isSmallScreenWidth, selectedMembers, styles, translate, canSelectMultiple, shouldUseNarrowLayout]); + + /** Opens the room member details page */ + const openRoomMemberDetails = useCallback( + (item: ListItem) => { + Navigation.navigate(ROUTES.ROOM_MEMBER_DETAILS.getRoute(report.reportID, item?.accountID ?? -1)); + }, + [report], + ); + const selectionModeHeader = selectionMode?.isEnabled && isSmallScreenWidth; + + const customListHeader = useMemo(() => { + const header = ( + <View style={[styles.flex1, styles.flexRow, styles.justifyContentBetween]}> + <View> + <Text style={[styles.searchInputStyle, canSelectMultiple ? styles.ml3 : styles.ml0]}>{translate('common.member')}</Text> + </View> + </View> + ); + + if (canSelectMultiple) { + return header; + } + + return <View style={[styles.peopleRow, styles.userSelectNone, styles.ph9, styles.pb5, styles.mt3]}>{header}</View>; + }, [styles, translate, canSelectMultiple]); + return ( <ScreenWrapper includeSafeAreaPaddingBottom={false} @@ -242,13 +349,20 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { }} > <HeaderWithBackButton - title={translate('workspace.common.members')} + title={selectionModeHeader ? translate('common.selectMultiple') : translate('workspace.common.members')} subtitle={StringUtils.lineBreaksToSpaces(ReportUtils.getReportName(report))} onBackButtonPress={() => { + if (selectionMode?.isEnabled) { + setSelectedMembers([]); + turnOffMobileSelectionMode(); + return; + } + setSearchValue(''); Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); }} /> + <View style={[styles.pl5, styles.pr5]}>{headerButtons}</View> <ConfirmModal danger title={translate('workspace.people.removeMembersTitle')} @@ -259,44 +373,31 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { confirmText={translate('common.remove')} cancelText={translate('common.cancel')} /> - <View style={[styles.w100, styles.flex1]}> - <View style={[styles.w100, styles.flexRow, styles.pt3, styles.ph5]}> - <Button - medium - success - text={translate('common.invite')} - onPress={inviteUser} - /> - <Button - medium - danger - style={[styles.ml2]} - isDisabled={selectedMembers.length === 0} - text={translate('common.remove')} - onPress={askForConfirmationToRemove} - /> - </View> - <View style={[styles.w100, styles.mt4, styles.flex1]}> - <SelectionList - canSelectMultiple - sections={[{data, isDisabled: false}]} - textInputLabel={translate('selectionList.findMember')} - disableKeyboardShortcuts={removeMembersConfirmModalVisible} - textInputValue={searchValue} - onChangeText={(value) => { - SearchInputManager.searchInput = value; - setSearchValue(value); - }} - headerMessage={headerMessage} - onSelectRow={(item) => toggleUser(item)} - onSelectAll={() => toggleAllUsers(data)} - showLoadingPlaceholder={!OptionsListUtils.isPersonalDetailsReady(personalDetails) || !didLoadRoomMembers} - showScrollIndicator - shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} - ListItem={UserListItem} - onDismissError={dismissError} - /> - </View> + <View style={[styles.w100, styles.mt3, styles.flex1]}> + <SelectionListWithModal + canSelectMultiple={canSelectMultiple} + sections={[{data, isDisabled: false}]} + shouldShowTextInput={shouldShowTextInput} + textInputLabel={translate('selectionList.findMember')} + disableKeyboardShortcuts={removeMembersConfirmModalVisible} + textInputValue={searchValue} + onChangeText={(value) => { + setSearchValue(value); + }} + headerMessage={headerMessage} + turnOnSelectionModeOnLongPress + onTurnOnSelectionMode={(item) => item && toggleUser(item)} + onCheckboxPress={(item) => toggleUser(item)} + onSelectRow={openRoomMemberDetails} + onSelectAll={() => toggleAllUsers(data)} + showLoadingPlaceholder={!OptionsListUtils.isPersonalDetailsReady(personalDetails) || !didLoadRoomMembers} + showScrollIndicator + shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} + listHeaderWrapperStyle={[styles.ph9, styles.mt3]} + customListHeader={customListHeader} + ListItem={TableListItem} + onDismissError={dismissError} + /> </View> </FullPageNotFoundView> </ScreenWrapper> diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 28567cfa8fe5..ac31c4e7f3b9 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -1,4 +1,3 @@ -import {Str} from 'expensify-common'; import React from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils'; @@ -98,11 +97,6 @@ const baseFilterConfig = { description: 'common.to' as const, route: ROUTES.SEARCH_ADVANCED_FILTERS_TO, }, - has: { - getTitle: getFilterHasDisplayTitle, - description: 'search.filters.has' as const, - route: ROUTES.SEARCH_ADVANCED_FILTERS_HAS, - }, in: { getTitle: getFilterInDisplayTitle, description: 'common.in' as const, @@ -114,7 +108,7 @@ const typeFiltersKeys: Record<string, Array<ValueOf<typeof CONST.SEARCH.SYNTAX_F [CONST.SEARCH.DATA_TYPES.EXPENSE]: ['date', 'currency', 'merchant', 'description', 'reportID', 'amount', 'category', 'keyword', 'taxRate', 'expenseType', 'tag', 'from', 'to', 'cardID'], [CONST.SEARCH.DATA_TYPES.INVOICE]: ['date', 'currency', 'merchant', 'description', 'reportID', 'amount', 'category', 'keyword', 'taxRate', 'tag', 'from', 'to', 'cardID'], [CONST.SEARCH.DATA_TYPES.TRIP]: ['date', 'currency', 'merchant', 'description', 'reportID', 'amount', 'category', 'keyword', 'taxRate', 'tag', 'from', 'to', 'cardID'], - [CONST.SEARCH.DATA_TYPES.CHAT]: ['date', 'keyword', 'from', 'has', 'in'], + [CONST.SEARCH.DATA_TYPES.CHAT]: ['date', 'keyword', 'from', 'in'], }; function getFilterCardDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, cards: CardList) { @@ -170,6 +164,8 @@ function getFilterDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, fiel if (greaterThan) { return translate('search.filters.amount.greaterThan', convertToDisplayStringWithoutCurrency(Number(greaterThan))); } + // Will never happen + return; } if ( @@ -184,11 +180,8 @@ function getFilterDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, fiel return filters[fieldName]; } - // Todo Once all Advanced filters are implemented this line can be cleaned up. See: https://github.com/Expensify/App/issues/45026 - // @ts-expect-error this property access is temporarily an error, because not every SYNTAX_FILTER_KEYS is handled by form. - // When all filters are updated here: src/types/form/SearchAdvancedFiltersForm.ts this line comment + type cast can be removed. - const filterValue = filters[fieldName] as string; - return filterValue ? Str.recapitalize(filterValue) : undefined; + const filterValue = filters[fieldName]; + return Array.isArray(filterValue) ? filterValue.join(', ') : filterValue; } function getFilterTaxRateDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, taxRates: Record<string, string[]>) { @@ -221,17 +214,6 @@ function getFilterExpenseDisplayTitle(filters: Partial<SearchAdvancedFiltersForm function getFilterInDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, translate: LocaleContextProps['translate'], reports?: OnyxCollection<Report>) { return filters.in ? filters.in.map((id) => ReportUtils.getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`])).join(', ') : undefined; } - -function getFilterHasDisplayTitle(filters: Partial<SearchAdvancedFiltersForm>, translate: LocaleContextProps['translate']) { - const filterValue = filters[CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS]; - return filterValue - ? Object.values(CONST.SEARCH.CHAT_TYPES) - .filter((hasFilter) => filterValue.includes(hasFilter)) - .map((hasFilter) => translate(SearchUtils.getChatFiltersTranslationKey(hasFilter))) - .join(', ') - : undefined; -} - function AdvancedSearchFilters() { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -246,8 +228,8 @@ function AdvancedSearchFilters() { const currentType = searchAdvancedFilters?.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; const onFormSubmit = () => { - const query = SearchUtils.buildQueryStringFromFilters(searchAdvancedFilters); - SearchActions.clearAdvancedFilters(); + const query = SearchUtils.buildQueryStringFromFilterValues(searchAdvancedFilters); + SearchActions.clearAllFilters(); Navigation.dismissModal(); Navigation.navigate( ROUTES.SEARCH_CENTRAL_PANE.getRoute({ @@ -278,7 +260,7 @@ function AdvancedSearchFilters() { filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters, cardList); } else if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters, taxRates); - } else if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS) { + } else if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE) { filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters, translate); } else if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters[key] ?? [], personalDetails); @@ -305,6 +287,7 @@ function AdvancedSearchFilters() { <MenuItemWithTopDescription key={filter.description} title={filter.title} + titleStyle={styles.flex1} description={filter.description} shouldShowRightIcon onPress={filter.onPress} diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 88b2a38b3603..1f259c96d625 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -32,6 +32,7 @@ function EmptySearchView({type}: EmptySearchViewProps) { buttonText: translate('search.searchResults.emptyTripResults.buttonText'), buttonAction: () => Navigation.navigate(ROUTES.TRAVEL_MY_TRIPS), }; + case CONST.SEARCH.DATA_TYPES.CHAT: case CONST.SEARCH.DATA_TYPES.EXPENSE: case CONST.SEARCH.DATA_TYPES.INVOICE: default: diff --git a/src/pages/Search/SearchAdvancedFiltersPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage.tsx index 9c205de2433b..687e04c7341b 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage.tsx @@ -6,6 +6,7 @@ import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@userActions/Search'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import AdvancedSearchFilters from './AdvancedSearchFilters'; @@ -17,7 +18,9 @@ function SearchAdvancedFiltersPage() { const emptySearchFilters: SearchAdvancedFiltersForm = {} as SearchAdvancedFiltersForm; const [searchAdvancedFilters = emptySearchFilters] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const shouldShowResetFilters = Object.values(searchAdvancedFilters).some((value) => (Array.isArray(value) ? value.length !== 0 : !!value)); + const shouldShowResetFilters = Object.entries(searchAdvancedFilters) + .filter(([key]) => CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE !== key && CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS !== key) + .some(([, value]) => (Array.isArray(value) ? value.length !== 0 : !!value)); return ( <ScreenWrapper diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersExpenseTypePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersExpenseTypePage.tsx index 65d474b41905..941602f8efe8 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersExpenseTypePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersExpenseTypePage.tsx @@ -65,6 +65,6 @@ function SearchFiltersExpenseTypePage() { ); } -SearchFiltersExpenseTypePage.displayName = 'SearchFiltersCategoryPage'; +SearchFiltersExpenseTypePage.displayName = 'SearchFiltersExpenseTypePage'; export default SearchFiltersExpenseTypePage; diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersHasPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersHasPage.tsx deleted file mode 100644 index e964554373b0..000000000000 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersHasPage.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, {useCallback, useMemo} from 'react'; -import {View} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SearchMultipleSelectionPicker from '@components/Search/SearchMultipleSelectionPicker'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; -import * as SearchActions from '@userActions/Search'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; - -type FilterItem = { - name: string; - value: typeof CONST.SEARCH.CHAT_TYPES.ATTACHMENT | typeof CONST.SEARCH.CHAT_TYPES.LINK; -}; - -function SearchFiltersHasPage() { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - - const filterItems: FilterItem[] = useMemo( - () => [ - { - name: translate('common.attachment'), - value: CONST.SEARCH.CHAT_TYPES.ATTACHMENT, - }, - { - name: translate('search.filters.link'), - value: CONST.SEARCH.CHAT_TYPES.LINK, - }, - ], - [translate], - ); - - const selectedOptions = useMemo(() => { - return searchAdvancedFiltersForm?.has?.map((value) => filterItems.find((filterItem) => filterItem.value === value)).filter((item): item is FilterItem => item !== undefined) ?? []; - }, [searchAdvancedFiltersForm, filterItems]); - - const updateHasFilter = useCallback((values: string[]) => SearchActions.updateAdvancedFilters({has: values}), []); - - return ( - <ScreenWrapper - testID={SearchFiltersHasPage.displayName} - shouldShowOfflineIndicatorInWideScreen - offlineIndicatorStyle={styles.mtAuto} - includeSafeAreaPaddingBottom={false} - shouldEnableMaxHeight - > - <HeaderWithBackButton - title={translate('search.filters.has')} - onBackButtonPress={() => { - Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS); - }} - /> - <View style={[styles.flex1]}> - <SearchMultipleSelectionPicker - pickerTitle={translate('search.filters.has')} - items={filterItems} - initiallySelectedItems={selectedOptions} - onSaveSelection={updateHasFilter} - shouldShowTextInput={false} - /> - </View> - </ScreenWrapper> - ); -} - -SearchFiltersHasPage.displayName = 'SearchFiltersHasPage'; - -export default SearchFiltersHasPage; diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTaxRatePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTaxRatePage.tsx index 713e9f5110b2..a1ad32db6308 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTaxRatePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersTaxRatePage.tsx @@ -67,6 +67,6 @@ function SearchFiltersTaxRatePage() { ); } -SearchFiltersTaxRatePage.displayName = 'SearchFiltersCategoryPage'; +SearchFiltersTaxRatePage.displayName = 'SearchFiltersTaxRatePage'; export default SearchFiltersTaxRatePage; diff --git a/src/pages/Search/SearchHoldReasonPage.tsx b/src/pages/Search/SearchHoldReasonPage.tsx index 7ea22421737d..edd85e78cec2 100644 --- a/src/pages/Search/SearchHoldReasonPage.tsx +++ b/src/pages/Search/SearchHoldReasonPage.tsx @@ -25,13 +25,12 @@ type SearchHoldReasonPageProps = { function SearchHoldReasonPage({route}: SearchHoldReasonPageProps) { const {translate} = useLocalize(); - const {currentSearchHash, selectedTransactions, clearSelectedTransactions} = useSearchContext(); + const {currentSearchHash, selectedTransactions} = useSearchContext(); const {backTo = ''} = route.params ?? {}; const selectedTransactionIDs = Object.keys(selectedTransactions); const onSubmit = (values: FormOnyxValues<typeof ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM>) => { SearchActions.holdMoneyRequestOnSearch(currentSearchHash, selectedTransactionIDs, values.comment); - clearSelectedTransactions(); Navigation.goBack(); }; diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 810e7140060f..281218415f9a 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -1,16 +1,21 @@ import React from 'react'; import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import MenuItem from '@components/MenuItem'; +import {usePersonalDetails} from '@components/OnyxProvider'; import type {SearchQueryJSON} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as SearchActions from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import {getAllTaxRates} from '@libs/PolicyUtils'; import * as SearchUtils from '@libs/SearchUtils'; import variables from '@styles/variables'; import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; @@ -34,6 +39,10 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const {singleExecution} = useSingleExecution(); const {translate} = useLocalize(); + const personalDetails = usePersonalDetails(); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const taxRates = getAllTaxRates(); + const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const typeMenuItems: SearchTypeMenuItem[] = [ { @@ -42,6 +51,12 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { icon: Expensicons.Receipt, route: ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: SearchUtils.buildCannedSearchQuery()}), }, + { + title: translate('common.chats'), + type: CONST.SEARCH.DATA_TYPES.CHAT, + icon: Expensicons.ChatBubbles, + route: ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.TRIP.ALL)}), + }, { title: translate('workspace.common.invoices'), type: CONST.SEARCH.DATA_TYPES.INVOICE, @@ -60,12 +75,13 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const activeItemIndex = isCannedQuery ? typeMenuItems.findIndex((item) => item.type === type) : -1; if (shouldUseNarrowLayout) { - const title = isCannedQuery ? undefined : SearchUtils.getSearchHeaderTitle(queryJSON); + const title = isCannedQuery ? undefined : SearchUtils.getSearchHeaderTitle(queryJSON, personalDetails, cardList, reports, taxRates); return ( <SearchTypeMenuNarrow typeMenuItems={typeMenuItems} activeItemIndex={activeItemIndex} + queryJSON={queryJSON} title={title} /> ); @@ -74,7 +90,10 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { return ( <View style={[styles.pb4, styles.mh3, styles.mt3]}> {typeMenuItems.map((item, index) => { - const onPress = singleExecution(() => Navigation.navigate(item.route)); + const onPress = singleExecution(() => { + SearchActions.clearAllFilters(); + Navigation.navigate(item.route); + }); return ( <MenuItem diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 0aa01dbb9fe4..3bbd594eb115 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -4,23 +4,27 @@ import Button from '@components/Button'; import Icon from '@components/Icon'; import PopoverMenu from '@components/PopoverMenu'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import type {SearchQueryJSON} from '@components/Search/types'; import Text from '@components/Text'; import useSingleExecution from '@hooks/useSingleExecution'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as SearchActions from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import * as Expensicons from '@src/components/Icon/Expensicons'; +import * as SearchUtils from '@src/libs/SearchUtils'; import ROUTES from '@src/ROUTES'; import type {SearchTypeMenuItem} from './SearchTypeMenu'; type SearchTypeMenuNarrowProps = { typeMenuItems: SearchTypeMenuItem[]; activeItemIndex: number; + queryJSON: SearchQueryJSON; title?: string; }; -function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, title}: SearchTypeMenuNarrowProps) { +function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title}: SearchTypeMenuNarrowProps) { const theme = useTheme(); const styles = useThemeStyles(); const {singleExecution} = useSingleExecution(); @@ -31,13 +35,21 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, title}: SearchTyp const openMenu = () => setIsPopoverVisible(true); const closeMenu = () => setIsPopoverVisible(false); + const onPress = () => { + const values = SearchUtils.getFiltersFormValues(queryJSON); + SearchActions.updateAdvancedFilters(values); + Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS); + }; const popoverMenuItems = typeMenuItems.map((item, index) => { const isSelected = title ? false : index === activeItemIndex; return { text: item.title, - onSelected: singleExecution(() => Navigation.navigate(item.route)), + onSelected: singleExecution(() => { + SearchActions.clearAllFilters(); + Navigation.navigate(item.route); + }), icon: item.icon, iconFill: isSelected ? theme.iconSuccessFill : theme.icon, iconRight: Expensicons.Checkmark, @@ -96,7 +108,7 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, title}: SearchTyp </PressableWithFeedback> <Button icon={Expensicons.Filters} - onPress={() => Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS)} + onPress={onPress} /> <PopoverMenu menuItems={popoverMenuItems} diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx index 96b32006675e..56c600fa1181 100644 --- a/src/pages/TransactionDuplicate/Confirmation.tsx +++ b/src/pages/TransactionDuplicate/Confirmation.tsx @@ -56,6 +56,7 @@ function Confirmation() { checkIfContextMenuActive: () => {}, reportNameValuePairs: undefined, anchor: null, + isDisabled: false, }), [report, reportAction], ); diff --git a/src/pages/TransactionReceiptPage.tsx b/src/pages/TransactionReceiptPage.tsx index 9a59c15b589c..60f5ad4dbd86 100644 --- a/src/pages/TransactionReceiptPage.tsx +++ b/src/pages/TransactionReceiptPage.tsx @@ -35,7 +35,7 @@ function TransactionReceipt({transaction, report, reportMetadata = {isLoadingIni const parentReportAction = ReportActionUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); const canEditReceipt = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT); - const isEReceipt = transaction && TransactionUtils.hasEReceipt(transaction); + const isEReceipt = transaction && !TransactionUtils.hasReceiptSource(transaction) && TransactionUtils.hasEReceipt(transaction); const isTrackExpenseAction = ReportActionUtils.isTrackExpenseAction(parentReportAction); useEffect(() => { diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 56b9cf970d54..4491a12e6bbd 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -196,7 +196,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro isWaitingOnBankAccount: reportOnyx?.isWaitingOnBankAccount, iouReportID: reportOnyx?.iouReportID, isOwnPolicyExpenseChat: reportOnyx?.isOwnPolicyExpenseChat, - notificationPreference: reportOnyx?.notificationPreference, isPinned: reportOnyx?.isPinned, chatReportID: reportOnyx?.chatReportID, visibility: reportOnyx?.visibility, @@ -240,7 +239,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro reportOnyx?.isWaitingOnBankAccount, reportOnyx?.iouReportID, reportOnyx?.isOwnPolicyExpenseChat, - reportOnyx?.notificationPreference, reportOnyx?.isPinned, reportOnyx?.chatReportID, reportOnyx?.visibility, @@ -550,7 +548,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro !isFocused || prevIsFocused || !ReportUtils.isChatThread(report) || - report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN || + ReportUtils.getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN || isSingleTransactionView ) { return; @@ -560,7 +558,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro // We don't want to run this useEffect every time `report` is changed // Excluding shouldUseNarrowLayout from the dependency list to prevent re-triggering on screen resize events. // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [prevIsFocused, report.notificationPreference, isFocused, isSingleTransactionView]); + }, [prevIsFocused, report.participants, isFocused, isSingleTransactionView]); useEffect(() => { // We don't want this effect to run on the first render. diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 21521396f347..f306c3953469 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -46,7 +46,7 @@ import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import getCursorPosition from '@pages/home/report/ReportActionCompose/getCursorPosition'; import getScrollPosition from '@pages/home/report/ReportActionCompose/getScrollPosition'; -import type {ComposerRef, SuggestionsRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose'; +import type {SuggestionsRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose'; import SilentCommentUpdater from '@pages/home/report/ReportActionCompose/SilentCommentUpdater'; import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions'; import * as EmojiPickerActions from '@userActions/EmojiPickerAction'; @@ -178,6 +178,19 @@ type SwitchToCurrentReportProps = { callback: () => void; }; +type ComposerRef = { + blur: () => void; + focus: (shouldDelay?: boolean) => void; + replaceSelectionWithText: EmojiPickerActions.OnEmojiSelected; + getCurrentText: () => string; + isFocused: () => boolean; + /** + * Calling clear will immediately clear the input on the UI thread (its a worklet). + * Once the composer ahs cleared onCleared will be called with the value that was cleared. + */ + clear: () => void; +}; + const {RNTextInputReset} = NativeModules; const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; @@ -833,4 +846,4 @@ export default withOnyx<ComposerWithSuggestionsProps & RefAttributes<ComposerRef }, })(memo(ComposerWithSuggestionsWithRef)); -export type {ComposerWithSuggestionsProps}; +export type {ComposerWithSuggestionsProps, ComposerRef}; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index fd194040c1e6..2bbf04ec3d2b 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -50,22 +50,9 @@ import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; -import type {ComposerWithSuggestionsProps} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {ComposerRef, ComposerWithSuggestionsProps} from './ComposerWithSuggestions/ComposerWithSuggestions'; import SendButton from './SendButton'; -type ComposerRef = { - blur: () => void; - focus: (shouldDelay?: boolean) => void; - replaceSelectionWithText: EmojiPickerActions.OnEmojiSelected; - getCurrentText: () => string; - isFocused: () => boolean; - /** - * Calling clear will immediately clear the input on the UI thread (its a worklet). - * Once the composer ahs cleared onCleared will be called with the value that was cleared. - */ - clear: () => void; -}; - type SuggestionsRef = { resetSuggestions: () => void; onSelectionChange?: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void; @@ -213,25 +200,13 @@ function ReportActionCompose({ const userBlockedFromConcierge = useMemo(() => User.isBlockedFromConcierge(blockedFromConcierge), [blockedFromConcierge]); const isBlockedFromConcierge = useMemo(() => includesConcierge && userBlockedFromConcierge, [includesConcierge, userBlockedFromConcierge]); - // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions - const conciergePlaceholderRandomIndex = useMemo( - () => Math.floor(Math.random() * (translate('reportActionCompose.conciergePlaceholderOptions').length - (shouldUseNarrowLayout ? 4 : 1) + 1)), - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - [], - ); - // Placeholder to display in the chat input. const inputPlaceholder = useMemo(() => { - if (includesConcierge) { - if (userBlockedFromConcierge) { - return translate('reportActionCompose.blockedFromConcierge'); - } - - return translate('reportActionCompose.conciergePlaceholderOptions')[conciergePlaceholderRandomIndex]; + if (includesConcierge && userBlockedFromConcierge) { + return translate('reportActionCompose.blockedFromConcierge'); } - return translate('reportActionCompose.writeSomething'); - }, [includesConcierge, translate, userBlockedFromConcierge, conciergePlaceholderRandomIndex]); + }, [includesConcierge, translate, userBlockedFromConcierge]); const focus = () => { if (composerRef.current === null) { diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index ff7fb4ff9238..98b626164146 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -395,6 +395,7 @@ function ReportActionItem({ action, transactionThreadReport, checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, + isDisabled: false, }), [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport, reportNameValuePairs], ); @@ -664,6 +665,8 @@ function ReportActionItem({ children = <ReportActionItemBasicMessage message={ReportActionsUtils.getPolicyChangeLogChangeRoleMessage(action)} />; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE) { children = <ReportActionItemBasicMessage message={ReportActionsUtils.getPolicyChangeLogDeleteMemberMessage(action)} />; + } else if (ReportActionsUtils.isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REMOVED_FROM_APPROVAL_CHAIN)) { + children = <ReportActionItemBasicMessage message={ReportActionsUtils.getRemovedFromApprovalChainMessage(action)} />; } else if ( ReportActionsUtils.isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED, CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED_VIRTUAL, CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS) ) { @@ -722,6 +725,7 @@ function ReportActionItem({ action={action} draftMessage={draftMessage} reportID={report.reportID} + policyID={report.policyID} index={index} ref={textInputRef} shouldDisableEmojiPicker={ diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 15b901689ddc..73bea7060b36 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -61,6 +61,9 @@ type ReportActionItemMessageEditProps = { /** ReportID that holds the comment we're editing */ reportID: string; + /** PolicyID of the policy the report belongs to */ + policyID?: string; + /** Position index of the report action in the overall report FlatList view */ index: number; @@ -68,7 +71,7 @@ type ReportActionItemMessageEditProps = { shouldDisableEmojiPicker?: boolean; /** Whether report is from group policy */ - isGroupPolicyReport?: boolean; + isGroupPolicyReport: boolean; }; // native ids @@ -81,7 +84,7 @@ const shouldUseForcedSelectionRange = shouldUseEmojiPickerSelection(); const draftMessageVideoAttributeCache = new Map<string, string>(); function ReportActionItemMessageEdit( - {action, draftMessage, reportID, index, isGroupPolicyReport, shouldDisableEmojiPicker = false}: ReportActionItemMessageEditProps, + {action, draftMessage, reportID, policyID, index, isGroupPolicyReport, shouldDisableEmojiPicker = false}: ReportActionItemMessageEditProps, forwardedRef: ForwardedRef<TextInput | HTMLTextAreaElement | undefined>, ) { const [preferredSkinTone] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, {initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE}); @@ -532,7 +535,8 @@ function ReportActionItemMessageEdit( isComposerFocused={textInputRef.current?.isFocused()} updateComment={updateDraft} measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} - isGroupPolicyReport={false} + isGroupPolicyReport={isGroupPolicyReport} + policyID={policyID} value={draft} selection={selection} setSelection={setSelection} diff --git a/src/pages/home/report/ReportActionItemParentAction.tsx b/src/pages/home/report/ReportActionItemParentAction.tsx index ff7e9d4d7027..d3842786b162 100644 --- a/src/pages/home/report/ReportActionItemParentAction.tsx +++ b/src/pages/home/report/ReportActionItemParentAction.tsx @@ -77,7 +77,11 @@ function ReportActionItemParentAction({ key: `${ONYXKEYS.COLLECTION.REPORT}${ancestorReportID}`, callback: (val) => { ancestorReports.current[ancestorReportID] = val; - setAllAncestors(ReportUtils.getAllAncestorReportActions(report)); + // getAllAncestorReportActions use getReportOrDraftReport to get parent reports which gets the report from allReports that + // holds the report collection. However, allReports is not updated by the time this current callback is called. + // Therefore we need to pass the up-to-date report to getAllAncestorReportActions so that it uses the up-to-date report value + // to calculate, for instance, unread marker. + setAllAncestors(ReportUtils.getAllAncestorReportActions(report, val)); }, }), ); diff --git a/src/pages/home/report/ReportAttachments.tsx b/src/pages/home/report/ReportAttachments.tsx index 7140cd2d45c4..369d5cef6ee4 100644 --- a/src/pages/home/report/ReportAttachments.tsx +++ b/src/pages/home/report/ReportAttachments.tsx @@ -6,6 +6,7 @@ import type {Attachment} from '@components/Attachments/types'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -55,7 +56,8 @@ function ReportAttachments({route}: ReportAttachmentsProps) { ComposerFocusManager.setReadyToFocus(); }} onCarouselAttachmentChange={onCarouselAttachmentChange} - shouldShowNotFoundPage={!isLoadingApp && !report?.reportID} + shouldShowNotFoundPage={!isLoadingApp && type !== CONST.ATTACHMENT_TYPE.SEARCH && !report?.reportID} + isAuthTokenRequired={type === CONST.ATTACHMENT_TYPE.SEARCH} /> ); } diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index ee8b7cde1971..2bf744868a9a 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -111,7 +111,8 @@ function ReportFooter({ // If a user just signed in and is viewing a public report, optimistically show the composer while loading the report, since they will have write access when the response comes back. const shouldShowComposerOptimistically = !isAnonymousUser && ReportUtils.isPublicRoom(report) && !!reportMetadata?.isLoadingInitialReportActions; - const shouldHideComposer = (!ReportUtils.canUserPerformWriteAction(report) && !shouldShowComposerOptimistically) || isBlockedFromChat; + const canPerformWriteAction = ReportUtils.canUserPerformWriteAction(report) ?? shouldShowComposerOptimistically; + const shouldHideComposer = !canPerformWriteAction || isBlockedFromChat; const canWriteInReport = ReportUtils.canWriteInReport(report); const isSystemChat = ReportUtils.isSystemChat(report); const isAdminsOnlyPostingRoom = ReportUtils.isAdminsOnlyPostingRoom(report); @@ -238,5 +239,7 @@ export default memo( prevProps.lastReportAction === nextProps.lastReportAction && prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay && prevProps.workspaceTooltip?.shouldShow === nextProps.workspaceTooltip?.shouldShow && - lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata), + lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) && + lodashIsEqual(prevProps.policy?.employeeList, nextProps.policy?.employeeList) && + lodashIsEqual(prevProps.policy?.role, nextProps.policy?.role), ); diff --git a/src/pages/home/report/withReportOrNotFound.tsx b/src/pages/home/report/withReportOrNotFound.tsx index 7f6165a031ee..c7cc6961b764 100644 --- a/src/pages/home/report/withReportOrNotFound.tsx +++ b/src/pages/home/report/withReportOrNotFound.tsx @@ -7,7 +7,7 @@ import {withOnyx} from 'react-native-onyx'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import * as ReportUtils from '@libs/ReportUtils'; -import type {ParticipantsNavigatorParamList, PrivateNotesNavigatorParamList, ReportDescriptionNavigatorParamList} from '@navigation/types'; +import type {ParticipantsNavigatorParamList, PrivateNotesNavigatorParamList, ReportDescriptionNavigatorParamList, RoomMembersNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import * as Report from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -37,7 +37,8 @@ type WithReportOrNotFoundProps = WithReportOrNotFoundOnyxProps & { | RouteProp<PrivateNotesNavigatorParamList, typeof SCREENS.PRIVATE_NOTES.EDIT> | RouteProp<ReportDescriptionNavigatorParamList, typeof SCREENS.REPORT_DESCRIPTION_ROOT> | RouteProp<ParticipantsNavigatorParamList, typeof SCREENS.REPORT_PARTICIPANTS.DETAILS> - | RouteProp<ParticipantsNavigatorParamList, typeof SCREENS.REPORT_PARTICIPANTS.ROLE>; + | RouteProp<ParticipantsNavigatorParamList, typeof SCREENS.REPORT_PARTICIPANTS.ROLE> + | RouteProp<RoomMembersNavigatorParamList, typeof SCREENS.ROOM_MEMBERS.DETAILS>; /** The report currently being looked at */ report: OnyxTypes.Report; @@ -84,7 +85,7 @@ export default function ( } if (shouldShowNotFoundPage) { - return <NotFoundPage />; + return <NotFoundPage isReportRelatedPage />; } } diff --git a/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx b/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx new file mode 100644 index 000000000000..0192d3d8423a --- /dev/null +++ b/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Avatar from '@components/Avatar'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as UserUtils from '@libs/UserUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ProfileAvatarWithIndicator from './ProfileAvatarWithIndicator'; + +type AvatarWithDelegateAvatarProps = { + /** Emoji status */ + delegateEmail: string; + + /** Whether the avatar is selected */ + isSelected?: boolean; +}; + +function AvatarWithDelegateAvatar({delegateEmail, isSelected = false}: AvatarWithDelegateAvatarProps) { + const styles = useThemeStyles(); + const personalDetails = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const delegatePersonalDetail = Object.values(personalDetails[0] ?? {}).find((personalDetail) => personalDetail?.login?.toLowerCase() === delegateEmail); + + return ( + <View style={styles.sidebarStatusAvatarContainer}> + <ProfileAvatarWithIndicator isSelected={isSelected} /> + <View style={[styles.sidebarStatusAvatar]}> + <View style={styles.emojiStatusLHN}> + <Avatar + size={CONST.AVATAR_SIZE.SMALL} + source={UserUtils.getSmallSizeAvatar(delegatePersonalDetail?.avatar, delegatePersonalDetail?.accountID)} + fallbackIcon={delegatePersonalDetail?.fallbackIcon} + type={CONST.ICON_TYPE_AVATAR} + /> + </View> + </View> + </View> + ); +} + +AvatarWithDelegateAvatar.displayName = 'AvatarWithDelegateAvatar'; + +export default AvatarWithDelegateAvatar; diff --git a/src/pages/home/sidebar/BottomTabAvatar.tsx b/src/pages/home/sidebar/BottomTabAvatar.tsx index c928fdbc7dd8..0a798389517b 100644 --- a/src/pages/home/sidebar/BottomTabAvatar.tsx +++ b/src/pages/home/sidebar/BottomTabAvatar.tsx @@ -1,4 +1,5 @@ import React, {useCallback} from 'react'; +import {useOnyx} from 'react-native-onyx'; import {PressableWithFeedback} from '@components/Pressable'; import Tooltip from '@components/Tooltip'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -7,7 +8,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import AvatarWithDelegateAvatar from './AvatarWithDelegateAvatar'; import AvatarWithOptionalStatus from './AvatarWithOptionalStatus'; import ProfileAvatarWithIndicator from './ProfileAvatarWithIndicator'; @@ -22,6 +25,8 @@ type BottomTabAvatarProps = { function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomTabAvatarProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const delegateEmail = account?.delegatedAccess?.delegate ?? ''; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const emojiStatus = currentUserPersonalDetails?.status?.emojiCode ?? ''; @@ -36,7 +41,14 @@ function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomT let children; - if (emojiStatus) { + if (delegateEmail) { + children = ( + <AvatarWithDelegateAvatar + delegateEmail={delegateEmail} + isSelected={isSelected} + /> + ); + } else if (emojiStatus) { children = ( <AvatarWithOptionalStatus emojiStatus={emojiStatus} diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index c7f4bbb96681..4444f519dbcd 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -262,7 +262,7 @@ function FloatingActionButtonAndPopover( selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true), true); return; case CONST.QUICK_ACTIONS.SPLIT_DISTANCE: - selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, true), true); + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.DISTANCE, false), true); return; case CONST.QUICK_ACTIONS.SEND_MONEY: selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.PAY, quickActionReportID, CONST.IOU.REQUEST_TYPE.MANUAL, true), false); diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index 81de69919ed3..ba406c3ddef6 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -323,6 +323,7 @@ function MoneyRequestAmountForm( horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }} + shouldShowPersonalBankAccountOption enterKeyEventListenerPriority={1} /> ) : ( diff --git a/src/pages/iou/SplitBillDetailsPage.tsx b/src/pages/iou/SplitBillDetailsPage.tsx index 0410c111ec93..efefc8ccc9ea 100644 --- a/src/pages/iou/SplitBillDetailsPage.tsx +++ b/src/pages/iou/SplitBillDetailsPage.tsx @@ -1,6 +1,6 @@ import type {StackScreenProps} from '@react-navigation/stack'; import type {ComponentType} from 'react'; -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -84,6 +84,7 @@ function SplitBillDetailsPage({personalDetails, report, route, reportActions, tr const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); const hasSmartScanFailed = TransactionUtils.hasReceipt(transaction) && transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCANFAILED; const isEditingSplitBill = session?.accountID === reportAction?.actorAccountID && TransactionUtils.areRequiredFieldsEmpty(transaction); + const [isConfirmed, setIsConfirmed] = useState(false); const { amount: splitAmount, @@ -95,10 +96,10 @@ function SplitBillDetailsPage({personalDetails, report, route, reportActions, tr billable: splitBillable, } = ReportUtils.getTransactionDetails(isEditingSplitBill && draftTransaction ? draftTransaction : transaction) ?? {}; - const onConfirm = useCallback( - () => IOU.completeSplitBill(reportID, reportAction, draftTransaction, session?.accountID ?? -1, session?.email ?? ''), - [reportID, reportAction, draftTransaction, session?.accountID, session?.email], - ); + const onConfirm = useCallback(() => { + setIsConfirmed(true); + IOU.completeSplitBill(reportID, reportAction, draftTransaction, session?.accountID ?? -1, session?.email ?? ''); + }, [reportID, reportAction, draftTransaction, session?.accountID, session?.email]); return ( <ScreenWrapper testID={SplitBillDetailsPage.displayName}> @@ -150,6 +151,7 @@ function SplitBillDetailsPage({personalDetails, report, route, reportActions, tr onToggleBillable={(billable) => { IOU.setDraftSplitTransaction(transaction?.transactionID ?? '-1', {billable}); }} + isConfirmed={isConfirmed} /> )} </View> diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 6c7769751c84..f10575f8c1d0 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -163,13 +163,15 @@ function MoneyRequestParticipantsSelector({participants = CONST.EMPTY_ARRAY, onF } const newOptions = OptionsListUtils.filterOptions(defaultOptions, debouncedSearchTerm, { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + canInviteUser: (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) && !isCategorizeOrShareAction, selectedOptions: participants as Participant[], excludeLogins: CONST.EXPENSIFY_EMAILS, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, preferPolicyExpenseChat: isPaidGroupPolicy, }); return newOptions; - }, [areOptionsInitialized, defaultOptions, debouncedSearchTerm, participants, isPaidGroupPolicy]); + }, [areOptionsInitialized, defaultOptions, debouncedSearchTerm, participants, isPaidGroupPolicy, canUseP2PDistanceRequests, iouRequestType, isCategorizeOrShareAction]); /** * Returns the sections needed for the OptionsSelector diff --git a/src/pages/iou/request/step/IOURequestStepCategory.tsx b/src/pages/iou/request/step/IOURequestStepCategory.tsx index e94ea724d1ed..f3388fcc7b0e 100644 --- a/src/pages/iou/request/step/IOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/IOURequestStepCategory.tsx @@ -16,6 +16,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; @@ -176,23 +177,25 @@ function IOURequestStepCategory({ subtitle={translate('workspace.categories.emptyCategories.subtitle')} containerStyle={[styles.flex1, styles.justifyContentCenter]} /> - <FixedFooter style={[styles.mtAuto, styles.pt5]}> - <Button - large - success - style={[styles.w100]} - onPress={() => - Navigation.navigate( - ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute( - policy?.id ?? '-1', - ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, report?.reportID ?? '-1', backTo, reportActionID), - ), - ) - } - text={translate('workspace.categories.editCategories')} - pressOnEnter - /> - </FixedFooter> + {PolicyUtils.isPolicyAdmin(policy) && ( + <FixedFooter style={[styles.mtAuto, styles.pt5]}> + <Button + large + success + style={[styles.w100]} + onPress={() => + Navigation.navigate( + ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute( + policy?.id ?? '-1', + ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, report?.reportID ?? '-1', backTo, reportActionID), + ), + ) + } + text={translate('workspace.categories.editCategories')} + pressOnEnter + /> + </FixedFooter> + )} </View> )} {!shouldShowEmptyState && !isLoading && ( diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index c43d537c5bfc..6c1457abef62 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -121,6 +121,7 @@ function IOURequestStepConfirmation({ }, [personalDetails, transaction?.participants, transaction?.splitPayerAccountIDs]); const gpsRequired = transaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && receiptFile; + const [isConfirmed, setIsConfirmed] = useState(false); const headerTitle = useMemo(() => { if (isCategorizingTrackExpense) { @@ -303,9 +304,22 @@ function IOURequestStepConfirmation({ transaction.actionableWhisperReportActionID, transaction.linkedTrackedExpenseReportAction, transaction.linkedTrackedExpenseReportID, + customUnitRateID, ); }, - [report, transaction, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, transactionTaxCode, transactionTaxAmount, policy, policyTags, policyCategories, action], + [ + report, + transaction, + currentUserPersonalDetails.login, + currentUserPersonalDetails.accountID, + transactionTaxCode, + transactionTaxAmount, + policy, + policyTags, + policyCategories, + action, + customUnitRateID, + ], ); const createDistanceRequest = useCallback( @@ -342,6 +356,7 @@ function IOURequestStepConfirmation({ const createTransaction = useCallback( (selectedParticipants: Participant[], locationPermissionGranted = false) => { + setIsConfirmed(true); let splitParticipants = selectedParticipants; // Filter out participants with an amount equal to O @@ -544,11 +559,13 @@ function IOURequestStepConfirmation({ } if (paymentMethod === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + setIsConfirmed(true); IOU.sendMoneyElsewhere(report, transaction.amount, currency, trimmedComment, currentUserPersonalDetails.accountID, participant); return; } if (paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + setIsConfirmed(true); IOU.sendMoneyWithWallet(report, transaction.amount, currency, trimmedComment, currentUserPersonalDetails.accountID, participant); } }, @@ -633,6 +650,7 @@ function IOURequestStepConfirmation({ action={action} payeePersonalDetails={payeePersonalDetails} shouldPlaySound={false} + isConfirmed={isConfirmed} /> </View> )} diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 9131bc4b3d39..38a98017e61a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -118,6 +118,7 @@ function IOURequestStepDistance({ const isCreatingNewRequest = !(backTo || isEditing); const [recentWaypoints, {status: recentWaypointsStatus}] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const iouRequestType = TransactionUtils.getRequestType(transaction); + const customUnitRateID = TransactionUtils.getRateID(transaction); // For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace // request and the workspace requires a category or a tag @@ -293,6 +294,11 @@ function IOURequestStepDistance({ undefined, undefined, TransactionUtils.getValidWaypoints(waypoints, true), + undefined, + undefined, + undefined, + undefined, + customUnitRateID, ); return; } @@ -346,6 +352,7 @@ function IOURequestStepDistance({ policy, iouRequestType, reportNameValuePairs, + customUnitRateID, ]); const getError = () => { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index 3b42b1d9a1be..8dde04202a22 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -54,7 +54,6 @@ function IOURequestStepDistanceRate({ const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${IOU.getIOURequestPolicyID(transaction, reportDraft) ?? '-1'}`); const policy = policyReal ?? policyDraft; - const rates = DistanceRequestUtils.getMileageRates(policy); const styles = useThemeStyles(); const {translate, toLocaleDigit} = useLocalize(); @@ -65,6 +64,8 @@ function IOURequestStepDistanceRate({ const currentRateID = TransactionUtils.getRateID(transaction) ?? '-1'; + const rates = DistanceRequestUtils.getMileageRates(policy, false, currentRateID); + const navigateBack = () => { Navigation.goBack(backTo); }; @@ -77,6 +78,7 @@ function IOURequestStepDistanceRate({ alternateText: rate.name ? rateForDisplay : '', keyForList: rate.customUnitRateID, value: rate.customUnitRateID, + isDisabled: !rate.enabled, isSelected: currentRateID ? currentRateID === rate.customUnitRateID : rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE, }; }); diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index eeacd56f8d5c..30f4cf010f70 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -370,11 +370,11 @@ function InitialSettingsPage({userWallet, bankAccountList, fundList, walletTerms const workspaceMenuItems = useMemo(() => getMenuItemsSection(workspaceMenuItemsData), [workspaceMenuItemsData, getMenuItemsSection]); const headerContent = ( - <View style={[styles.ph5, styles.pb3]}> + <View style={[styles.ph5, styles.pv5]}> {isEmptyObject(currentUserPersonalDetails) || currentUserPersonalDetails.displayName === undefined ? ( - <AccountSwitcherSkeletonView avatarSize={CONST.AVATAR_SIZE.MEDIUM} /> + <AccountSwitcherSkeletonView avatarSize={CONST.AVATAR_SIZE.DEFAULT} /> ) : ( - <View style={[styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, styles.pb3, styles.gap3]}> + <View style={[styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, styles.gap3]}> <AccountSwitcher /> <Tooltip text={translate('statusPage.status')}> <PressableWithFeedback @@ -433,13 +433,14 @@ function InitialSettingsPage({userWallet, bankAccountList, fundList, walletTerms includeSafeAreaPaddingBottom={false} testID={InitialSettingsPage.displayName} > + {headerContent} <ScrollView ref={scrollViewRef} onScroll={onScroll} scrollEventThrottle={16} - contentContainerStyle={[styles.w100, styles.pt4]} + contentContainerStyle={[styles.w100]} + showsVerticalScrollIndicator={false} > - {headerContent} {accountMenuItems} {workspaceMenuItems} {generalMenuItems} diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx index 73e97bd7aef5..9fcc28f51912 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx @@ -231,7 +231,7 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { > <MenuItem title={translate('contacts.setAsDefault')} - icon={Expensicons.Profile} + icon={Expensicons.Star} onPress={setAsDefault} /> </OfflineWithFeedback> diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx index 10c1bd1fd3e0..4ea878e82987 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx @@ -1,6 +1,6 @@ import type {StackScreenProps} from '@react-navigation/stack'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx, withOnyx} from 'react-native-onyx'; @@ -13,6 +13,7 @@ import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import ValidateCodeActionModal from '@components/ValidateCodeActionModal'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -36,30 +37,35 @@ type NewContactMethodPageOnyxProps = { type NewContactMethodPageProps = NewContactMethodPageOnyxProps & StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD>; function NewContactMethodPage({loginList, route}: NewContactMethodPageProps) { + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const contactMethod = account?.primaryLogin ?? ''; const styles = useThemeStyles(); const {translate} = useLocalize(); const loginInputRef = useRef<AnimatedTextInputRef>(null); + const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(false); const [pendingContactAction] = useOnyx(ONYXKEYS.PENDING_CONTACT_ACTION); + const loginData = loginList?.[pendingContactAction?.contactMethod ?? contactMethod]; + const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin'); const navigateBackTo = route?.params?.backTo ?? ROUTES.SETTINGS_PROFILE; const hasFailedToSendVerificationCode = !!pendingContactAction?.errorFields?.actionVerified; - const addNewContactMethod = useCallback((values: FormOnyxValues<typeof ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM>) => { + const handleValidateMagicCode = useCallback((values: FormOnyxValues<typeof ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM>) => { const phoneLogin = LoginUtils.getPhoneLogin(values.phoneOrEmail); const validateIfnumber = LoginUtils.validateNumber(phoneLogin); const submitDetail = (validateIfnumber || values.phoneOrEmail).trim().toLowerCase(); - - User.saveNewContactMethodAndRequestValidationCode(submitDetail); + User.addPendingContactMethod(submitDetail); + setIsValidateCodeActionModalVisible(true); }, []); - useEffect(() => { - if (!pendingContactAction?.validateCodeSent) { - return; - } - - Navigation.navigate(ROUTES.SETINGS_CONTACT_METHOD_VALIDATE_ACTION); - }, [pendingContactAction]); + const addNewContactMethod = useCallback( + (magicCode: string) => { + User.addNewContactMethod(pendingContactAction?.contactMethod ?? '', magicCode); + Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS.route); + }, + [pendingContactAction?.contactMethod], + ); useEffect(() => () => User.clearUnvalidatedNewContactMethodAction(), []); @@ -113,7 +119,7 @@ function NewContactMethodPage({loginList, route}: NewContactMethodPageProps) { <FormProvider formID={ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM} validate={validate} - onSubmit={addNewContactMethod} + onSubmit={handleValidateMagicCode} submitButtonText={translate('common.add')} style={[styles.flexGrow1, styles.mh5]} > @@ -139,6 +145,16 @@ function NewContactMethodPage({loginList, route}: NewContactMethodPageProps) { /> )} </FormProvider> + <ValidateCodeActionModal + validatePendingAction={pendingContactAction?.pendingFields?.actionVerified} + validateError={validateLoginError} + handleSubmitForm={addNewContactMethod} + clearError={() => User.clearContactMethodErrors(contactMethod, 'validateLogin')} + onClose={() => setIsValidateCodeActionModalVisible(false)} + isVisible={isValidateCodeActionModalVisible} + title={contactMethod} + description={translate('contacts.enterMagicCode', {contactMethod})} + /> </ScreenWrapper> ); } diff --git a/src/pages/settings/Report/NotificationPreferencePage.tsx b/src/pages/settings/Report/NotificationPreferencePage.tsx index 3a149e1ed377..fd256d685139 100644 --- a/src/pages/settings/Report/NotificationPreferencePage.tsx +++ b/src/pages/settings/Report/NotificationPreferencePage.tsx @@ -22,17 +22,18 @@ function NotificationPreferencePage({report}: NotificationPreferencePageProps) { const {translate} = useLocalize(); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID || -1}`); const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); + const currentNotificationPreference = ReportUtils.getReportNotificationPreference(report); const shouldDisableNotificationPreferences = ReportUtils.isArchivedRoom(report, reportNameValuePairs) || ReportUtils.isSelfDM(report) || - (!isMoneyRequestReport && report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + (!isMoneyRequestReport && currentNotificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); const notificationPreferenceOptions = Object.values(CONST.REPORT.NOTIFICATION_PREFERENCE) .filter((pref) => pref !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) .map((preference) => ({ value: preference, text: translate(`notificationPreferencesPage.notificationPreferences.${preference}`), keyForList: preference, - isSelected: preference === report?.notificationPreference, + isSelected: preference === currentNotificationPreference, })); return ( @@ -49,7 +50,7 @@ function NotificationPreferencePage({report}: NotificationPreferencePageProps) { sections={[{data: notificationPreferenceOptions}]} ListItem={RadioListItem} onSelectRow={(option) => - report && ReportActions.updateNotificationPreference(report.reportID, report.notificationPreference, option.value, true, undefined, undefined, report) + report && ReportActions.updateNotificationPreference(report.reportID, currentNotificationPreference, option.value, true, undefined, undefined, report) } shouldSingleExecuteRowSelect initiallyFocusedOptionKey={notificationPreferenceOptions.find((locale) => locale.isSelected)?.keyForList} diff --git a/src/pages/settings/Report/ReportSettingsPage.tsx b/src/pages/settings/Report/ReportSettingsPage.tsx index 6e1ab9a61737..6a9986b5550f 100644 --- a/src/pages/settings/Report/ReportSettingsPage.tsx +++ b/src/pages/settings/Report/ReportSettingsPage.tsx @@ -33,9 +33,10 @@ function ReportSettingsPage({report, policies}: ReportSettingsPageProps) { const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const shouldDisableSettings = isEmptyObject(report) || ReportUtils.isArchivedRoom(report, reportNameValuePairs) || ReportUtils.isSelfDM(report); + const notificationPreferenceValue = ReportUtils.getReportNotificationPreference(report); const notificationPreference = - report?.notificationPreference && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN - ? translate(`notificationPreferencesPage.notificationPreferences.${report.notificationPreference}`) + notificationPreferenceValue && notificationPreferenceValue !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN + ? translate(`notificationPreferencesPage.notificationPreferences.${notificationPreferenceValue}`) : ''; const writeCapability = ReportUtils.isAdminRoom(report) ? CONST.REPORT.WRITE_CAPABILITIES.ADMINS : report?.writeCapability ?? CONST.REPORT.WRITE_CAPABILITIES.ALL; @@ -43,7 +44,7 @@ function ReportSettingsPage({report, policies}: ReportSettingsPageProps) { const shouldAllowWriteCapabilityEditing = useMemo(() => ReportUtils.canEditWriteCapability(report, linkedWorkspace), [report, linkedWorkspace]); const shouldAllowChangeVisibility = useMemo(() => ReportUtils.canEditRoomVisibility(report, linkedWorkspace), [report, linkedWorkspace]); - const shouldShowNotificationPref = !isMoneyRequestReport && report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const shouldShowNotificationPref = !isMoneyRequestReport && notificationPreferenceValue !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const shouldShowWriteCapability = !isMoneyRequestReport; diff --git a/src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx new file mode 100644 index 000000000000..eae54fa88c2a --- /dev/null +++ b/src/pages/settings/Security/AddDelegate/AddDelegatePage.tsx @@ -0,0 +1,168 @@ +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {useBetas} from '@components/OnyxProvider'; +import {useOptionsList} from '@components/OptionListContextProvider'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import UserListItem from '@components/SelectionList/UserListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ReportActions from '@libs/actions/Report'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Participant} from '@src/types/onyx/IOU'; + +function useOptions() { + const betas = useBetas(); + const [isLoading, setIsLoading] = useState(true); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const {options: optionsList, areOptionsInitialized} = useOptionsList(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const existingDelegates = useMemo(() => account?.delegatedAccess?.delegates?.map((delegate) => delegate.email) ?? [], [account?.delegatedAccess?.delegates]); + + const defaultOptions = useMemo(() => { + const {recentReports, personalDetails, userToInvite, currentUserOption} = OptionsListUtils.getFilteredOptions( + optionsList.reports, + optionsList.personalDetails, + betas, + '', + [], + [...CONST.EXPENSIFY_EMAILS, ...existingDelegates], + false, + true, + false, + {}, + [], + false, + {}, + [], + true, + false, + false, + 0, + ); + + const headerMessage = OptionsListUtils.getHeaderMessage((recentReports?.length || 0) + (personalDetails?.length || 0) !== 0 || !!currentUserOption, !!userToInvite, ''); + + if (isLoading) { + setIsLoading(false); + } + + return { + userToInvite, + recentReports, + personalDetails, + currentUserOption, + headerMessage, + categoryOptions: [], + tagOptions: [], + taxRatesOptions: [], + }; + }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, isLoading]); + + const options = useMemo(() => { + const filteredOptions = OptionsListUtils.filterOptions(defaultOptions, debouncedSearchValue.trim(), { + excludeLogins: [...CONST.EXPENSIFY_EMAILS, ...existingDelegates], + maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, + }); + const headerMessage = OptionsListUtils.getHeaderMessage( + (filteredOptions.recentReports?.length || 0) + (filteredOptions.personalDetails?.length || 0) !== 0 || !!filteredOptions.currentUserOption, + !!filteredOptions.userToInvite, + debouncedSearchValue, + ); + + return { + ...filteredOptions, + headerMessage, + }; + }, [debouncedSearchValue, defaultOptions, existingDelegates]); + + return {...options, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized}; +} +function AddDelegatePage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); + const {userToInvite, recentReports, personalDetails, searchValue, debouncedSearchValue, setSearchValue, headerMessage, areOptionsInitialized} = useOptions(); + + const sections = useMemo(() => { + const sectionsList = []; + + sectionsList.push({ + title: translate('common.recents'), + data: recentReports, + shouldShow: recentReports?.length > 0, + }); + + sectionsList.push({ + title: translate('common.contacts'), + data: personalDetails, + shouldShow: personalDetails?.length > 0, + }); + + if (userToInvite) { + sectionsList.push({ + title: undefined, + data: [userToInvite], + shouldShow: true, + }); + } + + return sectionsList.map((section) => ({ + ...section, + data: section.data.map((option) => ({ + ...option, + text: option.text ?? '', + alternateText: option.alternateText ?? undefined, + keyForList: option.keyForList ?? '', + isDisabled: option.isDisabled ?? undefined, + login: option.login ?? undefined, + shouldShowSubscript: option.shouldShowSubscript ?? undefined, + })), + })); + }, [personalDetails, recentReports, translate, userToInvite]); + + const onSelectRow = useCallback((option: Participant) => { + Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(option?.login ?? '')); + }, []); + + useEffect(() => { + ReportActions.searchInServer(debouncedSearchValue); + }, [debouncedSearchValue]); + + return ( + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + testID={AddDelegatePage.displayName} + > + <HeaderWithBackButton + title={translate('delegate.addCopilot')} + onBackButtonPress={() => Navigation.goBack()} + /> + <View style={[styles.flex1, styles.w100, styles.pRelative]}> + <SelectionList + sections={areOptionsInitialized ? sections : []} + ListItem={UserListItem} + onSelectRow={onSelectRow} + shouldSingleExecuteRowSelect + onChangeText={setSearchValue} + textInputValue={searchValue} + headerMessage={headerMessage} + textInputLabel={translate('selectionList.nameEmailOrPhoneNumber')} + showLoadingPlaceholder={!areOptionsInitialized} + isLoadingNewOptions={!!isSearchingForReports} + /> + </View> + </ScreenWrapper> + ); +} + +AddDelegatePage.displayName = 'AddDelegatePage'; + +export default AddDelegatePage; diff --git a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx new file mode 100644 index 000000000000..8c8292b1f320 --- /dev/null +++ b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx @@ -0,0 +1,83 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import type {ValueOf} from 'type-fest'; +import Button from '@components/Button'; +import HeaderPageLayout from '@components/HeaderPageLayout'; +import {FallbackAvatar} from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {requestValidationCode} from '@libs/actions/Delegate'; +import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type ConfirmDelegatePageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM>; + +function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) { + const {translate} = useLocalize(); + + const styles = useThemeStyles(); + const login = route.params.login; + const role = route.params.role as ValueOf<typeof CONST.DELEGATE_ROLE>; + const {isOffline} = useNetwork(); + + const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(login); + + const avatarIcon = personalDetails?.avatar ?? FallbackAvatar; + const formattedLogin = formatPhoneNumber(login ?? ''); + const displayName = personalDetails?.displayName ?? formattedLogin; + + const submitButton = ( + <Button + success + isDisabled={isOffline} + large + text={translate('delegate.addCopilot')} + style={styles.mt6} + pressOnEnter + onPress={() => { + requestValidationCode(); + Navigation.navigate(ROUTES.SETTINGS_DELEGATE_MAGIC_CODE.getRoute(login, role)); + }} + /> + ); + + return ( + <HeaderPageLayout + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(login, role))} + title={translate('delegate.addCopilot')} + testID={ConfirmDelegatePage.displayName} + footer={submitButton} + childrenContainerStyles={[styles.pt3, styles.gap6]} + > + <Text style={[styles.ph5]}>{translate('delegate.confirmCopilot')}</Text> + <MenuItem + avatarID={personalDetails?.accountID ?? -1} + iconType={CONST.ICON_TYPE_AVATAR} + icon={avatarIcon} + title={displayName} + description={formattedLogin} + interactive={false} + /> + <MenuItemWithTopDescription + title={translate('delegate.role', role)} + description={translate('delegate.accessLevel')} + helperText={translate('delegate.roleDescription', role)} + onPress={() => Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(login, role))} + shouldShowRightIcon + /> + </HeaderPageLayout> + ); +} + +ConfirmDelegatePage.displayName = 'ConfirmDelegatePage'; + +export default ConfirmDelegatePage; diff --git a/src/pages/settings/Security/AddDelegate/DelegateMagicCodePage.tsx b/src/pages/settings/Security/AddDelegate/DelegateMagicCodePage.tsx new file mode 100644 index 000000000000..9497507f041a --- /dev/null +++ b/src/pages/settings/Security/AddDelegate/DelegateMagicCodePage.tsx @@ -0,0 +1,73 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useEffect, useRef} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import type CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import ValidateCodeForm from './ValidateCodeForm'; +import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; + +type DelegateMagicCodePageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM>; + +function DelegateMagicCodePage({route}: DelegateMagicCodePageProps) { + const {translate} = useLocalize(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const login = route.params.login; + const role = route.params.role as ValueOf<typeof CONST.DELEGATE_ROLE>; + + const styles = useThemeStyles(); + const validateCodeFormRef = useRef<ValidateCodeFormHandle>(null); + + const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === login); + + useEffect(() => { + if (!currentDelegate || !!currentDelegate.pendingFields?.email || !!currentDelegate.errorFields?.addDelegate) { + return; + } + + // Dismiss modal on successful magic code verification + Navigation.dismissModal(); + }, [login, currentDelegate, role]); + + const onBackButtonPress = () => { + Navigation.goBack(ROUTES.SETTINGS_DELEGATE_CONFIRM.getRoute(login, role)); + }; + + return ( + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + shouldEnableMaxHeight + testID={DelegateMagicCodePage.displayName} + offlineIndicatorStyle={styles.mtAuto} + > + {({safeAreaPaddingBottomStyle}) => ( + <> + <HeaderWithBackButton + title={translate('delegate.makeSureItIsYou')} + onBackButtonPress={onBackButtonPress} + /> + <Text style={[styles.mb3, styles.ph5]}>{translate('delegate.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})}</Text> + <ValidateCodeForm + ref={validateCodeFormRef} + delegate={login} + role={role} + wrapperStyle={safeAreaPaddingBottomStyle} + /> + </> + )} + </ScreenWrapper> + ); +} + +DelegateMagicCodePage.displayName = 'DelegateMagicCodePage'; + +export default DelegateMagicCodePage; diff --git a/src/pages/settings/Security/AddDelegate/SelectDelegateRolePage.tsx b/src/pages/settings/Security/AddDelegate/SelectDelegateRolePage.tsx new file mode 100644 index 000000000000..e295a9a7ece7 --- /dev/null +++ b/src/pages/settings/Security/AddDelegate/SelectDelegateRolePage.tsx @@ -0,0 +1,69 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type SelectDelegateRolePageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE>; + +function SelectDelegateRolePage({route}: SelectDelegateRolePageProps) { + const {translate} = useLocalize(); + const login = route.params.login; + + const styles = useThemeStyles(); + const roleOptions = Object.values(CONST.DELEGATE_ROLE).map((role) => ({ + value: role, + text: translate('delegate.role', role), + keyForList: role, + alternateText: translate('delegate.roleDescription', role), + isSelected: role === route.params.role, + })); + + return ( + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + testID={SelectDelegateRolePage.displayName} + > + <HeaderWithBackButton + title={translate('delegate.accessLevel')} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_ADD_DELEGATE)} + /> + <SelectionList + isAlternateTextMultilineSupported + alternateTextNumberOfLines={4} + headerContent={ + <Text style={[styles.ph5, styles.pb5, styles.pt3]}> + <> + {translate('delegate.accessLevelDescription')}{' '} + <TextLink + style={[styles.link]} + href={CONST.COPILOT_HELP_URL} + > + {translate('common.learnMore')} + </TextLink> + </> + </Text> + } + onSelectRow={(option) => { + Navigation.navigate(ROUTES.SETTINGS_DELEGATE_CONFIRM.getRoute(login, option.value)); + }} + sections={[{data: roleOptions}]} + ListItem={RadioListItem} + /> + </ScreenWrapper> + ); +} + +SelectDelegateRolePage.displayName = 'SelectDelegateRolePage'; + +export default SelectDelegateRolePage; diff --git a/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx new file mode 100644 index 000000000000..c9816862ad35 --- /dev/null +++ b/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -0,0 +1,208 @@ +import {useFocusEffect} from '@react-navigation/native'; +import type {ForwardedRef} from 'react'; +import React, {useCallback, useImperativeHandle, useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; +import MagicCodeInput from '@components/MagicCodeInput'; +import type {AutoCompleteVariant, MagicCodeInputHandle} from '@components/MagicCodeInput'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import * as Delegate from '@userActions/Delegate'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {DelegateRole} from '@src/types/onyx/Account'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type ValidateCodeFormHandle = { + focus: () => void; + focusLastSelected: () => void; +}; + +type ValidateCodeFormError = { + validateCode?: TranslationPaths; +}; + +type BaseValidateCodeFormProps = { + /** Specifies autocomplete hints for the system, so it can provide autofill */ + autoComplete?: AutoCompleteVariant; + + /** Forwarded inner ref */ + innerRef?: ForwardedRef<ValidateCodeFormHandle>; + + /** The email of the delegate */ + delegate: string; + + /** The role of the delegate */ + role: DelegateRole; + + /** Any additional styles to apply */ + wrapperStyle?: StyleProp<ViewStyle>; +}; + +function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => {}, delegate, role, wrapperStyle}: BaseValidateCodeFormProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const [formError, setFormError] = useState<ValidateCodeFormError>({}); + const [validateCode, setValidateCode] = useState(''); + const inputValidateCodeRef = useRef<MagicCodeInputHandle>(null); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const login = account?.primaryLogin; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case + const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null); + + const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === delegate); + const validateLoginError = ErrorUtils.getLatestErrorField(currentDelegate, 'addDelegate'); + + const shouldDisableResendValidateCode = !!isOffline || currentDelegate?.isLoading; + + useImperativeHandle(innerRef, () => ({ + focus() { + inputValidateCodeRef.current?.focus(); + }, + focusLastSelected() { + if (!inputValidateCodeRef.current) { + return; + } + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + focusTimeoutRef.current = setTimeout(() => { + inputValidateCodeRef.current?.focusLastSelected(); + }, CONST.ANIMATED_TRANSITION); + }, + })); + + useFocusEffect( + useCallback(() => { + if (!inputValidateCodeRef.current) { + return; + } + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + focusTimeoutRef.current = setTimeout(() => { + inputValidateCodeRef.current?.focusLastSelected(); + }, CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, []), + ); + + /** + * Request a validate code / magic code be sent to verify this contact method + */ + const resendValidateCode = () => { + if (!login) { + return; + } + Delegate.requestValidationCode(); + + inputValidateCodeRef.current?.clear(); + }; + + /** + * Handle text input and clear formError upon text change + */ + const onTextInput = useCallback( + (text: string) => { + setValidateCode(text); + setFormError({}); + if (validateLoginError) { + Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'addDelegate'); + } + }, + [currentDelegate?.email, validateLoginError], + ); + + /** + * Check that all the form fields are valid, then trigger the submit callback + */ + const validateAndSubmitForm = useCallback(() => { + if (!validateCode.trim()) { + setFormError({validateCode: 'validateCodeForm.error.pleaseFillMagicCode'}); + return; + } + + if (!ValidationUtils.isValidValidateCode(validateCode)) { + setFormError({validateCode: 'validateCodeForm.error.incorrectMagicCode'}); + return; + } + + setFormError({}); + + Delegate.addDelegate(delegate, role, validateCode); + }, [delegate, role, validateCode]); + + return ( + <View style={[styles.flex1, styles.justifyContentBetween, wrapperStyle]}> + <View style={[styles.ph5, styles.mt3]}> + <MagicCodeInput + autoComplete={autoComplete} + ref={inputValidateCodeRef} + name="validateCode" + value={validateCode} + onChangeText={onTextInput} + errorText={formError?.validateCode ? translate(formError?.validateCode) : Object.values(validateLoginError ?? {})[0] ?? ''} + hasError={!isEmptyObject(validateLoginError)} + onFulfill={validateAndSubmitForm} + autoFocus={false} + /> + <OfflineWithFeedback errorRowStyles={[styles.mt2]}> + <View style={[styles.mt2, styles.dFlex, styles.flexColumn, styles.alignItemsStart]}> + <PressableWithFeedback + disabled={shouldDisableResendValidateCode} + style={[styles.mr1]} + onPress={resendValidateCode} + underlayColor={theme.componentBG} + hoverDimmingValue={1} + pressDimmingValue={0.2} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('validateCodeForm.magicCodeNotReceived')} + > + <Text style={[StyleUtils.getDisabledLinkStyles(shouldDisableResendValidateCode)]}>{translate('validateCodeForm.magicCodeNotReceived')}</Text> + </PressableWithFeedback> + </View> + </OfflineWithFeedback> + </View> + <FixedFooter> + <OfflineWithFeedback> + <Button + isDisabled={isOffline} + text={translate('common.verify')} + onPress={validateAndSubmitForm} + style={[styles.mt4]} + success + pressOnEnter + large + isLoading={currentDelegate?.isLoading} + /> + </OfflineWithFeedback> + </FixedFooter> + </View> + ); +} + +BaseValidateCodeForm.displayName = 'BaseValidateCodeForm'; + +export type {BaseValidateCodeFormProps, ValidateCodeFormHandle}; + +export default BaseValidateCodeForm; diff --git a/src/pages/settings/Security/AddDelegate/ValidateCodeForm/index.android.tsx b/src/pages/settings/Security/AddDelegate/ValidateCodeForm/index.android.tsx new file mode 100644 index 000000000000..02b9181877cb --- /dev/null +++ b/src/pages/settings/Security/AddDelegate/ValidateCodeForm/index.android.tsx @@ -0,0 +1,14 @@ +import React, {forwardRef} from 'react'; +import BaseValidateCodeForm from './BaseValidateCodeForm'; +import type {BaseValidateCodeFormProps, ValidateCodeFormHandle} from './BaseValidateCodeForm'; + +const ValidateCodeForm = forwardRef<ValidateCodeFormHandle, BaseValidateCodeFormProps>((props, ref) => ( + <BaseValidateCodeForm + autoComplete="sms-otp" + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + innerRef={ref} + /> +)); + +export default ValidateCodeForm; diff --git a/src/pages/settings/Security/AddDelegate/ValidateCodeForm/index.tsx b/src/pages/settings/Security/AddDelegate/ValidateCodeForm/index.tsx new file mode 100644 index 000000000000..0a1a217183cc --- /dev/null +++ b/src/pages/settings/Security/AddDelegate/ValidateCodeForm/index.tsx @@ -0,0 +1,14 @@ +import React, {forwardRef} from 'react'; +import BaseValidateCodeForm from './BaseValidateCodeForm'; +import type {BaseValidateCodeFormProps, ValidateCodeFormHandle} from './BaseValidateCodeForm'; + +const ValidateCodeForm = forwardRef<ValidateCodeFormHandle, BaseValidateCodeFormProps>((props, ref) => ( + <BaseValidateCodeForm + autoComplete="one-time-code" + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + innerRef={ref} + /> +)); + +export default ValidateCodeForm; diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx index 3420e352e056..1de5cb58bf38 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.tsx +++ b/src/pages/settings/Security/SecuritySettingsPage.tsx @@ -1,28 +1,53 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; +import {FallbackAvatar} from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import LottieAnimations from '@components/LottieAnimations'; +import MenuItem from '@components/MenuItem'; +import type {MenuItemProps} from '@components/MenuItem'; import MenuItemList from '@components/MenuItemList'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; +import {clearAddDelegateErrors} from '@libs/actions/Delegate'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; +import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; +import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; function SecuritySettingsPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); const waitForNavigate = useWaitForNavigation(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const theme = useTheme(); + const {canUseNewDotCopilot} = usePermissions(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false; - const menuItems = useMemo(() => { + const delegates = account?.delegatedAccess?.delegates ?? []; + const delegators = account?.delegatedAccess?.delegators ?? []; + + const hasDelegates = delegates.length > 0; + const hasDelegators = delegators.length > 0; + + const securityMenuItems = useMemo(() => { const baseMenuItems = [ { translationKey: 'twoFactorAuth.headerTitle', @@ -47,6 +72,61 @@ function SecuritySettingsPage() { })); }, [translate, waitForNavigate, styles]); + const delegateMenuItems: MenuItemProps[] = delegates + .filter((d) => !d.optimisticAccountID) + .map(({email, role, pendingAction, errorFields}) => { + const personalDetail = getPersonalDetailByEmail(email); + + const error = ErrorUtils.getLatestErrorField({errorFields}, 'addDelegate'); + + const onPress = () => { + if (isEmptyObject(pendingAction)) { + return; + } + if (!role) { + Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(email)); + return; + } + Navigation.navigate(ROUTES.SETTINGS_DELEGATE_MAGIC_CODE.getRoute(email, role)); + }; + + const formattedEmail = formatPhoneNumber(email); + return { + title: personalDetail?.displayName ?? formattedEmail, + description: personalDetail?.displayName ? formattedEmail : '', + badgeText: translate('delegate.role', role), + avatarID: personalDetail?.accountID ?? -1, + icon: personalDetail?.avatar ?? FallbackAvatar, + iconType: CONST.ICON_TYPE_AVATAR, + numberOfLinesDescription: 1, + wrapperStyle: [styles.sectionMenuItemTopDescription], + iconRight: Expensicons.ThreeDots, + shouldShowRightIcon: true, + pendingAction, + shouldForceOpacity: !!pendingAction, + onPendingActionDismiss: () => clearAddDelegateErrors(email, 'addDelegate'), + error, + onPress, + }; + }); + + const delegatorMenuItems: MenuItemProps[] = delegators.map(({email, role}) => { + const personalDetail = getPersonalDetailByEmail(email); + const formattedEmail = formatPhoneNumber(email); + + return { + title: personalDetail?.displayName ?? formattedEmail, + description: personalDetail?.displayName ? formattedEmail : '', + badgeText: translate('delegate.role', role), + avatarID: personalDetail?.accountID ?? -1, + icon: personalDetail?.avatar ?? '', + iconType: CONST.ICON_TYPE_AVATAR, + numberOfLinesDescription: 1, + wrapperStyle: [styles.sectionMenuItemTopDescription], + interactive: false, + }; + }); + return ( <ScreenWrapper testID={SecuritySettingsPage.displayName} @@ -54,30 +134,79 @@ function SecuritySettingsPage() { shouldEnablePickerAvoiding={false} shouldShowOfflineIndicatorInWideScreen > - <HeaderWithBackButton - title={translate('initialSettingsPage.security')} - shouldShowBackButton={shouldUseNarrowLayout} - onBackButtonPress={() => Navigation.goBack()} - icon={Illustrations.LockClosed} - /> - <ScrollView contentContainerStyle={styles.pt3}> - <View style={[styles.flex1, shouldUseNarrowLayout ? styles.workspaceSectionMobile : styles.workspaceSection]}> - <Section - title={translate('securityPage.title')} - subtitle={translate('securityPage.subtitle')} - isCentralPane - subtitleMuted - illustration={LottieAnimations.Safe} - titleStyles={styles.accountSettingsSectionTitle} - childrenStyles={styles.pt5} - > - <MenuItemList - menuItems={menuItems} - shouldUseSingleExecution - /> - </Section> - </View> - </ScrollView> + {({safeAreaPaddingBottomStyle}) => ( + <> + <HeaderWithBackButton + title={translate('initialSettingsPage.security')} + shouldShowBackButton={shouldUseNarrowLayout} + onBackButtonPress={() => Navigation.goBack()} + icon={Illustrations.LockClosed} + /> + <ScrollView contentContainerStyle={styles.pt3}> + <View style={[styles.flex1, shouldUseNarrowLayout ? styles.workspaceSectionMobile : styles.workspaceSection]}> + <Section + title={translate('securityPage.title')} + subtitle={translate('securityPage.subtitle')} + isCentralPane + subtitleMuted + illustration={LottieAnimations.Safe} + titleStyles={styles.accountSettingsSectionTitle} + childrenStyles={styles.pt5} + > + <MenuItemList + menuItems={securityMenuItems} + shouldUseSingleExecution + /> + </Section> + {canUseNewDotCopilot && ( + <View style={safeAreaPaddingBottomStyle}> + <Section + title={translate('delegate.copilotDelegatedAccess')} + renderSubtitle={() => ( + <View style={[styles.flexRow, styles.alignItemsCenter, styles.w100, styles.mt2]}> + <Text style={[styles.textNormal, styles.colorMuted]}>{translate('delegate.copilotDelegatedAccessDescription')} </Text> + <TextLink + style={[styles.link]} + href={CONST.COPILOT_HELP_URL} + > + {translate('common.learnMore')} + </TextLink> + </View> + )} + isCentralPane + subtitleMuted + titleStyles={styles.accountSettingsSectionTitle} + childrenStyles={styles.pt5} + > + {hasDelegates && ( + <> + <Text style={[styles.textLabelSupporting, styles.pv1]}>{translate('delegate.membersCanAccessYourAccount')}</Text> + <MenuItemList menuItems={delegateMenuItems} /> + </> + )} + {!isActingAsDelegate && ( + <MenuItem + title={translate('delegate.addCopilot')} + icon={Expensicons.UserPlus} + iconFill={theme.iconSuccessFill} + onPress={() => Navigation.navigate(ROUTES.SETTINGS_ADD_DELEGATE)} + shouldShowRightIcon + wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mb6]} + /> + )} + {hasDelegators && ( + <> + <Text style={[styles.textLabelSupporting, styles.pv1]}>{translate('delegate.youCanAccessTheseAccounts')}</Text> + <MenuItemList menuItems={delegatorMenuItems} /> + </> + )} + </Section> + </View> + )} + </View> + </ScrollView> + </> + )} </ScreenWrapper> ); } diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.tsx b/src/pages/settings/Wallet/ExpensifyCardPage.tsx index 4fec05f51e13..f96ff761669b 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage.tsx @@ -2,7 +2,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx, withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import CardPreview from '@components/CardPreview'; @@ -13,6 +13,7 @@ import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import ValidateCodeActionModal from '@components/ValidateCodeActionModal'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -81,9 +82,12 @@ function ExpensifyCardPage({ params: {cardID = ''}, }, }: ExpensifyCardPageProps) { + const [account] = useOnyx(ONYXKEYS.ACCOUNT); const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {translate} = useLocalize(); + const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(false); + const [currentCardID, setCurrentCardID] = useState<number>(-1); const shouldDisplayCardDomain = !cardList?.[cardID]?.nameValuePairs?.issuedBy || !cardList?.[cardID]?.nameValuePairs?.isVirtual; const domain = cardList?.[cardID]?.domainName ?? ''; const pageTitle = shouldDisplayCardDomain ? translate('cardPage.expensifyCard') : cardList?.[cardID]?.nameValuePairs?.cardTitle ?? translate('cardPage.expensifyCard'); @@ -105,30 +109,38 @@ function ExpensifyCardPage({ const [isCardDetailsLoading, setIsCardDetailsLoading] = useState<Record<number, boolean>>({}); const [cardsDetailsErrors, setCardsDetailsErrors] = useState<Record<number, string>>({}); - const handleRevealDetails = (revealedCardID: number) => { + const openValidateCodeModal = (revealedCardID: number) => { + setCurrentCardID(revealedCardID); + setIsValidateCodeActionModalVisible(true); + }; + + const handleRevealDetails = (validateCode: string) => { setIsCardDetailsLoading((prevState: Record<number, boolean>) => ({ ...prevState, - [revealedCardID]: true, + [currentCardID]: true, })); // We can't store the response in Onyx for security reasons. // That is why this action is handled manually and the response is stored in a local state // Hence eslint disable here. // eslint-disable-next-line rulesdir/no-thenable-actions-in-views - Card.revealVirtualCardDetails(revealedCardID) + Card.revealVirtualCardDetails(currentCardID, validateCode) .then((value) => { - setCardsDetails((prevState: Record<number, ExpensifyCardDetails | null>) => ({...prevState, [revealedCardID]: value})); + setCardsDetails((prevState: Record<number, ExpensifyCardDetails | null>) => ({...prevState, [currentCardID]: value})); setCardsDetailsErrors((prevState) => ({ ...prevState, - [revealedCardID]: '', + [currentCardID]: '', })); }) .catch((error: string) => { setCardsDetailsErrors((prevState) => ({ ...prevState, - [revealedCardID]: error, + [currentCardID]: error, })); }) - .finally(() => setIsCardDetailsLoading((prevState: Record<number, boolean>) => ({...prevState, [revealedCardID]: false}))); + .finally(() => { + setIsCardDetailsLoading((prevState: Record<number, boolean>) => ({...prevState, [currentCardID]: false})); + setIsValidateCodeActionModalVisible(false); + }); }; const hasDetectedDomainFraud = cardsToShow?.some((card) => card?.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN); @@ -232,14 +244,14 @@ function ExpensifyCardPage({ <Button medium text={translate('cardPage.cardDetails.revealDetails')} - onPress={() => handleRevealDetails(card.cardID)} + onPress={() => openValidateCodeModal(card.cardID)} isDisabled={isCardDetailsLoading[card.cardID] || isOffline} isLoading={isCardDetailsLoading[card.cardID]} /> } /> <DotIndicatorMessage - messages={cardsDetailsErrors[card.cardID] ? {error: cardsDetailsErrors[card.cardID]} : {}} + messages={cardsDetailsErrors[card.cardID] ? {error: translate(cardsDetailsErrors[card.cardID] as TranslationPaths)} : {}} type="error" style={[styles.ph5]} /> @@ -301,6 +313,14 @@ function ExpensifyCardPage({ style={[styles.mh5, styles.mb5]} /> )} + <ValidateCodeActionModal + handleSubmitForm={handleRevealDetails} + clearError={() => {}} + onClose={() => setIsValidateCodeActionModalVisible(false)} + isVisible={isValidateCodeActionModalVisible} + title={translate('cardPage.validateCardTitle')} + description={translate('cardPage.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} + /> </> )} </ScreenWrapper> diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx index 03b69a8f73ab..49c27d4e3c67 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx +++ b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx @@ -3,7 +3,7 @@ import type {ForwardedRef, RefObject} from 'react'; import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; import type {GestureResponderEvent} from 'react-native'; import {ActivityIndicator, Dimensions, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu'; import ConfirmModal from '@components/ConfirmModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -40,7 +40,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {AccountData} from '@src/types/onyx'; -import type {FormattedSelectedPaymentMethodIcon, WalletPageOnyxProps, WalletPageProps} from './types'; +import type {FormattedSelectedPaymentMethodIcon, WalletPageProps} from './types'; type FormattedSelectedPaymentMethod = { title: string; @@ -57,7 +57,14 @@ type PaymentMethodState = { selectedPaymentMethodType: string; }; -function WalletPage({bankAccountList = {}, cardList = {}, fundList = {}, isLoadingPaymentMethods = true, shouldListenForResize = false, userWallet, walletTerms = {}}: WalletPageProps) { +function WalletPage({shouldListenForResize = false}: WalletPageProps) { + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {initialValue: {}}); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST, {initialValue: {}}); + const [fundList] = useOnyx(ONYXKEYS.FUND_LIST, {initialValue: {}}); + const [isLoadingPaymentMethods] = useOnyx(ONYXKEYS.IS_LOADING_PAYMENT_METHODS, {initialValue: true}); + const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET); + const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS, {initialValue: {}}); + const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -617,26 +624,4 @@ function WalletPage({bankAccountList = {}, cardList = {}, fundList = {}, isLoadi WalletPage.displayName = 'WalletPage'; -export default withOnyx<WalletPageProps, WalletPageOnyxProps>({ - cardList: { - key: ONYXKEYS.CARD_LIST, - }, - walletTransfer: { - key: ONYXKEYS.WALLET_TRANSFER, - }, - userWallet: { - key: ONYXKEYS.USER_WALLET, - }, - bankAccountList: { - key: ONYXKEYS.BANK_ACCOUNT_LIST, - }, - fundList: { - key: ONYXKEYS.FUND_LIST, - }, - walletTerms: { - key: ONYXKEYS.WALLET_TERMS, - }, - isLoadingPaymentMethods: { - key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS, - }, -})(WalletPage); +export default WalletPage; diff --git a/src/pages/settings/Wallet/WalletPage/types.ts b/src/pages/settings/Wallet/WalletPage/types.ts index ffee0c677c33..f8abb7e0c017 100644 --- a/src/pages/settings/Wallet/WalletPage/types.ts +++ b/src/pages/settings/Wallet/WalletPage/types.ts @@ -1,31 +1,7 @@ import type {ViewStyle} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import type {BankAccountList, CardList, FundList, UserWallet, WalletTerms, WalletTransfer} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; -type WalletPageOnyxProps = { - /** Wallet balance transfer props */ - walletTransfer: OnyxEntry<WalletTransfer>; - - /** The user's wallet account */ - userWallet: OnyxEntry<UserWallet>; - - /** List of bank accounts */ - bankAccountList: OnyxEntry<BankAccountList>; - - /** List of user's cards */ - fundList: OnyxEntry<FundList>; - - /** Information about the user accepting the terms for payments */ - walletTerms: OnyxEntry<WalletTerms>; - - cardList: OnyxEntry<CardList>; - - /** Are we loading payment methods? */ - isLoadingPaymentMethods: OnyxEntry<boolean>; -}; - -type WalletPageProps = WalletPageOnyxProps & { +type WalletPageProps = { /** Listen for window resize event on web and desktop. */ shouldListenForResize?: boolean; }; @@ -38,4 +14,4 @@ type FormattedSelectedPaymentMethodIcon = { iconSize?: number; }; -export type {WalletPageOnyxProps, WalletPageProps, FormattedSelectedPaymentMethodIcon}; +export type {WalletPageProps, FormattedSelectedPaymentMethodIcon}; diff --git a/src/pages/workspace/SearchInputManager.ts b/src/pages/workspace/SearchInputManager.ts deleted file mode 100644 index 599f7cca6cf9..000000000000 --- a/src/pages/workspace/SearchInputManager.ts +++ /dev/null @@ -1,5 +0,0 @@ -// eslint-disable-next-line prefer-const -let searchInput = ''; -export default { - searchInput, -}; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index f88e387f0039..3c2bbe15a2d0 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -71,9 +71,6 @@ type WorkspaceMenuItem = { }; type WorkspaceInitialPageOnyxProps = { - /** Bank account attached to free plan */ - reimbursementAccount: OnyxEntry<OnyxTypes.ReimbursementAccount>; - /** Collection of categories attached to a policy */ policyCategories: OnyxEntry<OnyxTypes.PolicyCategories>; }; @@ -91,7 +88,7 @@ function dismissError(policyID: string, pendingAction: PendingAction | undefined } } -function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAccount, policyCategories, route}: WorkspaceInitialPageProps) { +function WorkspaceInitialPage({policyDraft, policy: policyProp, policyCategories, route}: WorkspaceInitialPageProps) { const styles = useThemeStyles(); const policy = policyDraft?.id ? policyDraft : policyProp; const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false); @@ -165,51 +162,8 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc const {login} = useCurrentUserPersonalDetails(); const shouldShowProtectedItems = PolicyUtils.isPolicyAdmin(policy, login); const isPaidGroupPolicy = PolicyUtils.isPaidGroupPolicy(policy); - const isFreeGroupPolicy = PolicyUtils.isFreeGroupPolicy(policy); const [featureStates, setFeatureStates] = useState(policyFeatureStates); - const protectedFreePolicyMenuItems: WorkspaceMenuItem[] = [ - { - translationKey: 'workspace.common.card', - icon: Expensicons.ExpensifyCard, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_CARD.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.CARD, - }, - { - translationKey: 'workspace.common.reimburse', - icon: Expensicons.Receipt, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_REIMBURSE.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.REIMBURSE, - }, - { - translationKey: 'workspace.common.bills', - icon: Expensicons.Bill, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_BILLS.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.BILLS, - }, - { - translationKey: 'workspace.common.invoices', - icon: Expensicons.Invoice, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.INVOICES, - }, - { - translationKey: 'workspace.common.travel', - icon: Expensicons.Luggage, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_TRAVEL.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.TRAVEL, - }, - { - translationKey: 'workspace.common.bankAccount', - icon: Expensicons.Bank, - action: () => - policy?.outputCurrency === CONST.CURRENCY.USD - ? singleExecution(waitForNavigate(() => ReimbursementAccount.navigateToBankAccountRoute(policyID, Navigation.getActiveRouteWithoutParams())))() - : setIsCurrencyModalOpen(true), - brickRoadIndicator: !isEmptyObject(reimbursementAccount?.errors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - }, - ]; - const protectedCollectPolicyMenuItems: WorkspaceMenuItem[] = []; // We only update feature states if they aren't pending. @@ -241,7 +195,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED]) { protectedCollectPolicyMenuItems.push({ translationKey: 'workspace.common.expensifyCard', - icon: Expensicons.CreditCard, + icon: Expensicons.ExpensifyCard, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policyID)))), routeName: SCREENS.WORKSPACE.EXPENSIFY_CARD, }); @@ -346,7 +300,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc routeName: SCREENS.WORKSPACE.MEMBERS, }, ...(isPaidGroupPolicy && shouldShowProtectedItems ? protectedCollectPolicyMenuItems : []), - ...(isFreeGroupPolicy && shouldShowProtectedItems ? protectedFreePolicyMenuItems : []), ]; const prevPolicy = usePrevious(policy); @@ -475,10 +428,6 @@ WorkspaceInitialPage.displayName = 'WorkspaceInitialPage'; export default withPolicyAndFullscreenLoading( withOnyx<WorkspaceInitialPageProps, WorkspaceInitialPageOnyxProps>({ - // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM - reimbursementAccount: { - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - }, policyCategories: { key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${route.params?.policyID ?? '-1'}`, }, diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.tsx b/src/pages/workspace/WorkspaceInviteMessagePage.tsx index df34875f5fa6..ff5b7326af84 100644 --- a/src/pages/workspace/WorkspaceInviteMessagePage.tsx +++ b/src/pages/workspace/WorkspaceInviteMessagePage.tsx @@ -38,7 +38,6 @@ import INPUT_IDS from '@src/types/form/WorkspaceInviteMessageForm'; import type {InvitedEmailsToAccountIDs, PersonalDetailsList} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; -import SearchInputManager from './SearchInputManager'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; @@ -110,7 +109,6 @@ function WorkspaceInviteMessagePage({ // Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details Member.addMembersToWorkspace(invitedEmailsToAccountIDsDraft ?? {}, `${welcomeNoteSubject}\n\n${welcomeNote}`, route.params.policyID); debouncedSaveDraft(null); - SearchInputManager.searchInput = ''; Navigation.dismissModal(); }; diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx index c29819b3c6f9..ad48d15aa9df 100644 --- a/src/pages/workspace/WorkspaceInvitePage.tsx +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -38,7 +38,6 @@ import type {Beta, InvitedEmailsToAccountIDs} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; -import SearchInputManager from './SearchInputManager'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; @@ -77,7 +76,6 @@ function WorkspaceInvitePage({route, betas, invitedEmailsToAccountIDsDraft, poli }); useEffect(() => { - setSearchTerm(SearchInputManager.searchInput); return () => { Member.setWorkspaceInviteMembersDraft(route.params.policyID, {}); }; @@ -338,7 +336,6 @@ function WorkspaceInvitePage({route, betas, invitedEmailsToAccountIDsDraft, poli textInputLabel={translate('selectionList.nameEmailOrPhoneNumber')} textInputValue={searchTerm} onChangeText={(value) => { - SearchInputManager.searchInput = value; setSearchTerm(value); }} headerMessage={headerMessage} diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index d7c8d0a5f60b..1fa8f9b3b240 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -600,7 +600,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson textInputRef={textInputRef} customListHeader={getCustomListHeader()} listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} - listHeaderContent={shouldUseNarrowLayout ? <View style={[styles.pl5, styles.pr5]}>{getHeaderContent()}</View> : null} + listHeaderContent={shouldUseNarrowLayout ? <View style={[styles.pr5]}>{getHeaderContent()}</View> : null} showScrollIndicator={false} /> </View> diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx index f030b8257315..2bc5c6bb1cd8 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx @@ -409,7 +409,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro title={translate('workspace.common.moreFeatures')} shouldShowBackButton={shouldUseNarrowLayout} /> - <Text style={[styles.pl5, styles.mb4, styles.mt3, styles.textSupporting, shouldUseNarrowLayout ? styles.workspaceSectionMobile : styles.workspaceSection]}> + <Text style={[styles.ph5, styles.mb4, styles.mt3, styles.textSupporting, shouldUseNarrowLayout ? styles.workspaceSectionMobile : styles.workspaceSection]}> {translate('workspace.moreFeatures.subtitle')} </Text> diff --git a/src/pages/workspace/accounting/AccountingContext.tsx b/src/pages/workspace/accounting/AccountingContext.tsx index ce5ac90fd2f1..6250f99b21f2 100644 --- a/src/pages/workspace/accounting/AccountingContext.tsx +++ b/src/pages/workspace/accounting/AccountingContext.tsx @@ -1,6 +1,7 @@ import type {MutableRefObject, RefObject} from 'react'; import React, {useContext, useMemo, useRef, useState} from 'react'; import type {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import AccountingConnectionConfirmationModal from '@components/AccountingConnectionConfirmationModal'; import useLocalize from '@hooks/useLocalize'; import {removePolicyConnection} from '@libs/actions/connections'; @@ -47,18 +48,26 @@ const defaultAccountingContext = { const AccountingContext = React.createContext<AccountingContextType>(defaultAccountingContext); type AccountingContextProviderProps = ChildrenProps & { - policy: Policy; + policy: OnyxEntry<Policy>; }; function AccountingContextProvider({children, policy}: AccountingContextProviderProps) { const popoverAnchorRefs = useRef<Record<string, MutableRefObject<View | null>>>(defaultAccountingContext.popoverAnchorRefs.current); const [activeIntegration, setActiveIntegration] = useState<ActiveIntegrationState>(); const {translate} = useLocalize(); - const policyID = policy.id; + const policyID = policy?.id ?? '-1'; const startIntegrationFlow = React.useCallback( (newActiveIntegration: ActiveIntegration) => { - const accountingIntegrationData = getAccountingIntegrationData(newActiveIntegration.name, policyID, translate); + const accountingIntegrationData = getAccountingIntegrationData( + newActiveIntegration.name, + policyID, + translate, + undefined, + undefined, + newActiveIntegration.integrationToDisconnect, + newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting, + ); const workspaceUpgradeNavigationDetails = accountingIntegrationData?.workspaceUpgradeNavigationDetails; if (workspaceUpgradeNavigationDetails && !isControlPolicy(policy)) { Navigation.navigate( @@ -119,7 +128,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider removePolicyConnection(policyID, activeIntegration?.integrationToDisconnect); closeConfirmationModal(); }} - integrationToConnect={activeIntegration?.integrationToDisconnect} + integrationToConnect={activeIntegration?.name} onCancel={() => { setActiveIntegration(undefined); }} diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index c125052c6935..2df5932b9260 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -1,4 +1,5 @@ -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {useFocusEffect, useRoute} from '@react-navigation/native'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -31,6 +32,7 @@ import { getCurrentXeroOrganizationName, getIntegrationLastSuccessfulDate, getXeroTenants, + isControlPolicy, settingsPendingAction, } from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -40,13 +42,20 @@ import type {AnchorPosition} from '@styles/index'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {ConnectionName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {AccountingContextProvider, useAccountingContext} from './AccountingContext'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; import {getAccountingIntegrationData, getSynchronizationErrorMessage} from './utils'; +type RouteParams = { + newConnectionName?: ConnectionName; + integrationToDisconnect?: ConnectionName; + shouldDisconnectIntegrationBeforeConnecting?: boolean; +}; + function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { - const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy.id}`); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); const theme = useTheme(); const styles = useThemeStyles(); const {translate, datetimeToRelative: getDatetimeToRelative} = useLocalize(); @@ -61,6 +70,12 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const {canUseWorkspaceFeeds} = usePermissions(); const {startIntegrationFlow, popoverAnchorRefs} = useAccountingContext(); + const route = useRoute(); + const params = route.params as RouteParams | undefined; + const newConnectionName = params?.newConnectionName; + const integrationToDisconnect = params?.integrationToDisconnect; + const shouldDisconnectIntegrationBeforeConnecting = params?.shouldDisconnectIntegrationBeforeConnecting; + const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); const accountingIntegrations = Object.values(CONST.POLICY.CONNECTIONS.NAME); @@ -113,6 +128,20 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { [shouldShowEnterCredentials, translate, isOffline, policyID, connectedIntegration, startIntegrationFlow], ); + useFocusEffect( + useCallback(() => { + if (!newConnectionName || !isControlPolicy(policy)) { + return; + } + + startIntegrationFlow({ + name: newConnectionName, + integrationToDisconnect, + shouldDisconnectIntegrationBeforeConnecting, + }); + }, [newConnectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting, policy, startIntegrationFlow]), + ); + useEffect(() => { if (successfulDate) { setDateTimeToRelative(getDatetimeToRelative(successfulDate)); @@ -224,7 +253,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { } const shouldShowSynchronizationError = !!synchronizationError; const shouldHideConfigurationOptions = isConnectionUnverified(policy, connectedIntegration); - const integrationData = getAccountingIntegrationData(connectedIntegration, policyID, translate, policy, undefined, canUseNetSuiteUSATax); + const integrationData = getAccountingIntegrationData(connectedIntegration, policyID, translate, policy, undefined, undefined, undefined, canUseNetSuiteUSATax); const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; const configurationOptions = [ diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListPage.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListPage.tsx index 794ecc4b118a..6b0016f833c1 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListPage.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListPage.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo, useRef} from 'react'; import type {ForwardedRef} from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import ConnectionLayout from '@components/ConnectionLayout'; import FormProvider from '@components/Form/FormProvider'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; @@ -36,7 +36,9 @@ function NetSuiteImportAddCustomListPage({policy}: WithPolicyConnectionsProps) { const customLists = useMemo(() => config?.syncOptions?.customLists ?? [], [config?.syncOptions]); const handleFinishStep = useCallback(() => { - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING.getRoute(policyID, CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_LISTS)); + InteractionManager.runAfterInteractions(() => { + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING.getRoute(policyID, CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_LISTS)); + }); }, [policyID]); const { diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentPage.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentPage.tsx index 85d164630f1b..a9d5893908b1 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentPage.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentPage.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo, useRef, useState} from 'react'; import type {ForwardedRef} from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import type {ValueOf} from 'type-fest'; import ConnectionLayout from '@components/ConnectionLayout'; import FormProvider from '@components/Form/FormProvider'; @@ -40,7 +40,9 @@ function NetSuiteImportAddCustomSegmentPage({policy}: WithPolicyConnectionsProps const customSegments = useMemo(() => config?.syncOptions?.customSegments ?? [], [config?.syncOptions]); const handleFinishStep = useCallback(() => { - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING.getRoute(policyID, CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_SEGMENTS)); + InteractionManager.runAfterInteractions(() => { + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING.getRoute(policyID, CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_SEGMENTS)); + }); }, [policyID]); const { diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportPage.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportPage.tsx index 7463a1ff656f..0c7ca49d1ec2 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportPage.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportPage.tsx @@ -24,7 +24,7 @@ function NetSuiteImportPage({policy}: WithPolicyConnectionsProps) { const {canUseNetSuiteUSATax} = usePermissions(); const policyID = policy?.id ?? '-1'; - const config = policy?.connections?.netsuite?.options.config; + const config = policy?.connections?.netsuite?.options?.config; const {subsidiaryList} = policy?.connections?.netsuite?.options?.data ?? {}; const selectedSubsidiary = useMemo(() => (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID), [subsidiaryList, config?.subsidiaryID]); diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAccountSelectPage.tsx index bed84acfb7ce..2a460504764b 100644 --- a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAccountSelectPage.tsx @@ -1,21 +1,21 @@ import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import BlockingView from '@components/BlockingViews/BlockingView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Illustrations from '@components/Icon/Illustrations'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; 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 * as Connections from '@libs/actions/connections'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import variables from '@styles/variables'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -29,7 +29,7 @@ function QuickbooksAccountSelectPage({policy}: WithPolicyConnectionsProps) { const policyID = policy?.id ?? '-1'; const {bankAccounts, creditCards} = policy?.connections?.quickbooksOnline?.data ?? {}; - const {reimbursementAccountID} = policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; const accountOptions = useMemo(() => [...(bankAccounts ?? []), ...(creditCards ?? [])], [bankAccounts, creditCards]); const qboOnlineSelectorOptions = useMemo<SelectorType[]>( @@ -38,9 +38,9 @@ function QuickbooksAccountSelectPage({policy}: WithPolicyConnectionsProps) { value: id, text: name, keyForList: id, - isSelected: reimbursementAccountID === id, + isSelected: qboConfig?.reimbursementAccountID === id, })), - [reimbursementAccountID, accountOptions], + [qboConfig?.reimbursementAccountID, accountOptions], ); const listHeaderComponent = useMemo( () => ( @@ -55,10 +55,10 @@ function QuickbooksAccountSelectPage({policy}: WithPolicyConnectionsProps) { const saveSelection = useCallback( ({value}: SelectorType) => { - Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICK_BOOKS_CONFIG.REIMBURSEMENT_ACCOUNT_ID, value); + Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICKBOOKS_CONFIG.REIMBURSEMENT_ACCOUNT_ID, value, qboConfig?.reimbursementAccountID); Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID)); }, - [policyID], + [policyID, qboConfig?.reimbursementAccountID], ); const listEmptyContent = useMemo( @@ -76,28 +76,26 @@ function QuickbooksAccountSelectPage({policy}: WithPolicyConnectionsProps) { ); return ( - <AccessOrNotFoundWrapper - accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} + <SelectionScreen policyID={policyID} + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} - > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - testID={QuickbooksAccountSelectPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.qbo.advancedConfig.qboBillPaymentAccount')} /> - - <SelectionList - sections={qboOnlineSelectorOptions.length ? [{data: qboOnlineSelectorOptions}] : []} - ListItem={RadioListItem} - headerContent={listHeaderComponent} - onSelectRow={saveSelection} - shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={initiallyFocusedOptionKey} - listEmptyContent={listEmptyContent} - /> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + displayName={QuickbooksAccountSelectPage.displayName} + sections={qboOnlineSelectorOptions.length ? [{data: qboOnlineSelectorOptions}] : []} + listItem={RadioListItem} + headerContent={listHeaderComponent} + onSelectRow={saveSelection} + shouldSingleExecuteRowSelect + initiallyFocusedOptionKey={initiallyFocusedOptionKey} + listEmptyContent={listEmptyContent} + title="workspace.qbo.advancedConfig.qboBillPaymentAccount" + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID))} + pendingAction={settingsPendingAction([CONST.QUICKBOOKS_CONFIG.REIMBURSEMENT_ACCOUNT_ID], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.REIMBURSEMENT_ACCOUNT_ID)} + errorRowStyles={[styles.ph5, styles.mv3]} + onClose={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.REIMBURSEMENT_ACCOUNT_ID)} + /> ); } diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx index 385510cb7ff4..35cb2c36d915 100644 --- a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx +++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx @@ -1,10 +1,8 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; @@ -12,15 +10,18 @@ import * as Connections from '@libs/actions/connections'; import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; -import type {ToggleSettingOptionRowProps} from '@pages/workspace/workflows/ToggleSettingsOptionRow'; -import * as Policy from '@userActions/Policy/Policy'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +const reimbursementOrCollectionAccountIDs = [CONST.QUICKBOOKS_CONFIG.REIMBURSEMENT_ACCOUNT_ID, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID]; +const collectionAccountIDs = [CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID]; + function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); const waitForNavigate = useWaitForNavigation(); @@ -28,80 +29,98 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) { const policyID = policy?.id ?? '-1'; const qboConfig = policy?.connections?.quickbooksOnline?.config; - const {autoSync, syncPeople, autoCreateVendor, pendingFields, collectionAccountID, reimbursementAccountID, errorFields} = qboConfig ?? {}; const {bankAccounts, creditCards, otherCurrentAssetAccounts, vendors} = policy?.connections?.quickbooksOnline?.data ?? {}; - const {nonReimbursableBillDefaultVendor} = policy?.connections?.quickbooksOnline?.config ?? {}; - const nonReimbursableBillDefaultVendorObject = vendors?.find((vendor) => vendor.id === nonReimbursableBillDefaultVendor); + const nonReimbursableBillDefaultVendorObject = vendors?.find((vendor) => vendor.id === qboConfig?.nonReimbursableBillDefaultVendor); + const qboAccountOptions = useMemo(() => [...(bankAccounts ?? []), ...(creditCards ?? [])], [bankAccounts, creditCards]); const invoiceAccountCollectionOptions = useMemo(() => [...(bankAccounts ?? []), ...(otherCurrentAssetAccounts ?? [])], [bankAccounts, otherCurrentAssetAccounts]); - const isSyncReimbursedSwitchOn = !!collectionAccountID; + const isSyncReimbursedSwitchOn = !!qboConfig?.collectionAccountID; + const reimbursementAccountID = qboConfig?.reimbursementAccountID; const selectedQboAccountName = useMemo(() => qboAccountOptions?.find(({id}) => id === reimbursementAccountID)?.name, [qboAccountOptions, reimbursementAccountID]); + const collectionAccountID = qboConfig?.collectionAccountID; + const selectedInvoiceCollectionAccountName = useMemo( () => invoiceAccountCollectionOptions?.find(({id}) => id === collectionAccountID)?.name, [invoiceAccountCollectionOptions, collectionAccountID], ); + const sectionMenuItems = [ + { + title: selectedQboAccountName, + description: translate('workspace.qbo.advancedConfig.qboBillPaymentAccount'), + onPress: waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR.getRoute(policyID))), + subscribedSettings: reimbursementOrCollectionAccountIDs, + brickRoadIndicator: PolicyUtils.areSettingsInErrorFields(reimbursementOrCollectionAccountIDs, qboConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + pendingAction: PolicyUtils.settingsPendingAction(reimbursementOrCollectionAccountIDs, qboConfig?.pendingFields), + }, + { + title: selectedInvoiceCollectionAccountName, + description: translate('workspace.qbo.advancedConfig.qboInvoiceCollectionAccount'), + onPress: waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR.getRoute(policyID))), + subscribedSettings: collectionAccountIDs, + brickRoadIndicator: PolicyUtils.areSettingsInErrorFields(collectionAccountIDs, qboConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + pendingAction: PolicyUtils.settingsPendingAction(collectionAccountIDs, qboConfig?.pendingFields), + }, + ]; + const syncReimbursedSubMenuItems = () => ( <View style={[styles.mt3]}> - <OfflineWithFeedback pendingAction={pendingFields?.reimbursementAccountID}> - <MenuItemWithTopDescription - shouldShowRightIcon - title={selectedQboAccountName} - description={translate('workspace.qbo.advancedConfig.qboBillPaymentAccount')} - wrapperStyle={[styles.sectionMenuItemTopDescription]} - onPress={waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR.getRoute(policyID)))} - errorText={errorFields?.reimbursementAccountID ? translate('common.genericErrorMessage') : undefined} - brickRoadIndicator={errorFields?.reimbursementAccountID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - /> - </OfflineWithFeedback> - <OfflineWithFeedback pendingAction={pendingFields?.collectionAccountID}> - <MenuItemWithTopDescription - shouldShowRightIcon - title={selectedInvoiceCollectionAccountName} - description={translate('workspace.qbo.advancedConfig.qboInvoiceCollectionAccount')} - wrapperStyle={[styles.sectionMenuItemTopDescription]} - onPress={waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR.getRoute(policyID)))} - errorText={errorFields?.collectionAccountID ? translate('common.genericErrorMessage') : undefined} - brickRoadIndicator={errorFields?.collectionAccountID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - /> - </OfflineWithFeedback> + {sectionMenuItems.map((item) => ( + <OfflineWithFeedback pendingAction={item.pendingAction}> + <MenuItemWithTopDescription + shouldShowRightIcon + title={item.title} + description={item.description} + wrapperStyle={[styles.sectionMenuItemTopDescription]} + onPress={item.onPress} + brickRoadIndicator={item.brickRoadIndicator} + /> + </OfflineWithFeedback> + ))} </View> ); - const qboToggleSettingItems: ToggleSettingOptionRowProps[] = [ + const qboToggleSettingItems = [ { title: translate('workspace.accounting.autoSync'), subtitle: translate('workspace.qbo.advancedConfig.autoSyncDescription'), switchAccessibilityLabel: translate('workspace.qbo.advancedConfig.autoSyncDescription'), - isActive: !!autoSync?.enabled, + isActive: !!qboConfig?.autoSync?.enabled, onToggle: () => - Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICK_BOOKS_CONFIG.AUTO_SYNC, { - enabled: !autoSync?.enabled, - }), - pendingAction: pendingFields?.autoSync, - errors: ErrorUtils.getLatestErrorField(qboConfig ?? {}, CONST.QUICK_BOOKS_CONFIG.AUTO_SYNC), - onCloseError: () => Policy.clearQBOErrorField(policyID, CONST.QUICK_BOOKS_CONFIG.AUTO_SYNC), - wrapperStyle: styles.mv3, + Connections.updatePolicyConnectionConfig( + policyID, + CONST.POLICY.CONNECTIONS.NAME.QBO, + CONST.QUICKBOOKS_CONFIG.AUTO_SYNC, + { + enabled: !qboConfig?.autoSync?.enabled, + }, + { + enabled: qboConfig?.autoSync?.enabled, + }, + ), + subscribedSetting: CONST.QUICKBOOKS_CONFIG.ENABLED, + errors: ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.ENABLED), + pendingAction: settingsPendingAction([CONST.QUICKBOOKS_CONFIG.ENABLED], qboConfig?.pendingFields), }, { title: translate('workspace.qbo.advancedConfig.inviteEmployees'), subtitle: translate('workspace.qbo.advancedConfig.inviteEmployeesDescription'), switchAccessibilityLabel: translate('workspace.qbo.advancedConfig.inviteEmployeesDescription'), - isActive: !!syncPeople, - onToggle: () => Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICK_BOOKS_CONFIG.SYNC_PEOPLE, !syncPeople), - pendingAction: pendingFields?.syncPeople, - errors: ErrorUtils.getLatestErrorField(qboConfig ?? {}, CONST.QUICK_BOOKS_CONFIG.SYNC_PEOPLE), - onCloseError: () => Policy.clearQBOErrorField(policyID, CONST.QUICK_BOOKS_CONFIG.SYNC_PEOPLE), - wrapperStyle: styles.mv3, + isActive: !!qboConfig?.syncPeople, + onToggle: () => + Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICKBOOKS_CONFIG.SYNC_PEOPLE, !qboConfig?.syncPeople, qboConfig?.syncPeople), + subscribedSetting: CONST.QUICKBOOKS_CONFIG.SYNC_PEOPLE, + errors: ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.SYNC_PEOPLE), + pendingAction: settingsPendingAction([CONST.QUICKBOOKS_CONFIG.SYNC_PEOPLE], qboConfig?.pendingFields), }, { title: translate('workspace.qbo.advancedConfig.createEntities'), subtitle: translate('workspace.qbo.advancedConfig.createEntitiesDescription'), switchAccessibilityLabel: translate('workspace.qbo.advancedConfig.createEntitiesDescription'), - isActive: !!autoCreateVendor, - onToggle: (isOn) => { + isActive: !!qboConfig?.autoCreateVendor, + onToggle: (isOn: boolean) => { const nonReimbursableVendorUpdateValue = isOn ? policy?.connections?.quickbooksOnline?.data?.vendors?.[0]?.id ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE : CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE; @@ -110,19 +129,18 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) { QuickbooksOnline.updateQuickbooksOnlineAutoCreateVendor( policyID, { - [CONST.QUICK_BOOKS_CONFIG.AUTO_CREATE_VENDOR]: isOn, - [CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: nonReimbursableVendorUpdateValue, + [CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR]: isOn, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: nonReimbursableVendorUpdateValue, }, { - [CONST.QUICK_BOOKS_CONFIG.AUTO_CREATE_VENDOR]: !!autoCreateVendor, - [CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: nonReimbursableVendorCurrentValue, + [CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR]: !!qboConfig?.autoCreateVendor, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: nonReimbursableVendorCurrentValue, }, ); }, - pendingAction: pendingFields?.autoCreateVendor, - errors: ErrorUtils.getLatestErrorField(qboConfig ?? {}, CONST.QUICK_BOOKS_CONFIG.AUTO_CREATE_VENDOR), - onCloseError: () => Policy.clearQBOErrorField(policyID, CONST.QUICK_BOOKS_CONFIG.AUTO_CREATE_VENDOR), - wrapperStyle: styles.mv3, + subscribedSetting: CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR, + errors: ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR), + pendingAction: settingsPendingAction([CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR], qboConfig?.pendingFields), }, { title: translate('workspace.accounting.reimbursedReports'), @@ -133,50 +151,44 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) { Connections.updatePolicyConnectionConfig( policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, - CONST.QUICK_BOOKS_CONFIG.COLLECTION_ACCOUNT_ID, + CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID, isSyncReimbursedSwitchOn ? '' : [...qboAccountOptions, ...invoiceAccountCollectionOptions][0].id, + qboConfig?.collectionAccountID, ), - pendingAction: pendingFields?.collectionAccountID, - errors: ErrorUtils.getLatestErrorField(qboConfig ?? {}, CONST.QUICK_BOOKS_CONFIG.COLLECTION_ACCOUNT_ID), - onCloseError: () => Policy.clearQBOErrorField(policyID, CONST.QUICK_BOOKS_CONFIG.COLLECTION_ACCOUNT_ID), - subMenuItems: syncReimbursedSubMenuItems(), - wrapperStyle: styles.mv3, + subscribedSetting: CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID, + errors: ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID), + pendingAction: settingsPendingAction([CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID], qboConfig?.pendingFields), }, ]; return ( - <AccessOrNotFoundWrapper - policyID={policyID} + <ConnectionLayout + displayName={QuickbooksAdvancedPage.displayName} + headerTitle="workspace.accounting.advanced" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} + policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} + contentContainerStyle={[styles.pb2, styles.ph5]} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))} > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - shouldEnableMaxHeight - testID={QuickbooksAdvancedPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.accounting.advanced')} /> - - <ScrollView contentContainerStyle={[styles.ph5, styles.pb5]}> - {qboToggleSettingItems.map((item) => ( - <ToggleSettingOptionRow - key={item.title} - errors={item.errors} - onCloseError={item.onCloseError} - title={item.title} - subtitle={item.subtitle} - switchAccessibilityLabel={item.switchAccessibilityLabel} - shouldPlaceSubtitleBelowSwitch - wrapperStyle={item.wrapperStyle} - isActive={item.isActive} - onToggle={item.onToggle} - pendingAction={item.pendingAction} - subMenuItems={item.subMenuItems} - /> - ))} - </ScrollView> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + {qboToggleSettingItems.map((item) => ( + <ToggleSettingOptionRow + key={item.title} + title={item.title} + subtitle={item.subtitle} + switchAccessibilityLabel={item.switchAccessibilityLabel} + shouldPlaceSubtitleBelowSwitch + wrapperStyle={styles.mv3} + isActive={item.isActive} + onToggle={item.onToggle} + pendingAction={item.pendingAction} + errors={item.errors} + onCloseError={() => clearQBOErrorField(policyID, item.subscribedSetting)} + /> + ))} + {isSyncReimbursedSwitchOn && syncReimbursedSubMenuItems()} + </ConnectionLayout> ); } diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx index 69acda4e1ba6..29741e93d97d 100644 --- a/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx @@ -1,21 +1,21 @@ import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import BlockingView from '@components/BlockingViews/BlockingView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Illustrations from '@components/Icon/Illustrations'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; 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 * as Connections from '@libs/actions/connections'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import variables from '@styles/variables'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -30,7 +30,7 @@ function QuickbooksInvoiceAccountSelectPage({policy}: WithPolicyConnectionsProps const policyID = policy?.id ?? '-1'; const {bankAccounts, otherCurrentAssetAccounts} = policy?.connections?.quickbooksOnline?.data ?? {}; const accountOptions = useMemo(() => [...(bankAccounts ?? []), ...(otherCurrentAssetAccounts ?? [])], [bankAccounts, otherCurrentAssetAccounts]); - const {collectionAccountID} = policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; const qboOnlineSelectorOptions = useMemo<SelectorType[]>( () => @@ -38,9 +38,9 @@ function QuickbooksInvoiceAccountSelectPage({policy}: WithPolicyConnectionsProps value: id, text: name, keyForList: id, - isSelected: collectionAccountID === id, + isSelected: qboConfig?.collectionAccountID === id, })), - [collectionAccountID, accountOptions], + [qboConfig?.collectionAccountID, accountOptions], ); const listHeaderComponent = useMemo( @@ -56,10 +56,10 @@ function QuickbooksInvoiceAccountSelectPage({policy}: WithPolicyConnectionsProps const updateAccount = useCallback( ({value}: SelectorType) => { - Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICK_BOOKS_CONFIG.COLLECTION_ACCOUNT_ID, value); + Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID, value, qboConfig?.collectionAccountID); Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID)); }, - [policyID], + [policyID, qboConfig?.collectionAccountID], ); const listEmptyContent = useMemo( @@ -77,28 +77,26 @@ function QuickbooksInvoiceAccountSelectPage({policy}: WithPolicyConnectionsProps ); return ( - <AccessOrNotFoundWrapper + <SelectionScreen policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} - > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - testID={QuickbooksInvoiceAccountSelectPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.qbo.advancedConfig.qboInvoiceCollectionAccount')} /> - - <SelectionList - sections={qboOnlineSelectorOptions.length ? [{data: qboOnlineSelectorOptions}] : []} - ListItem={RadioListItem} - headerContent={listHeaderComponent} - onSelectRow={updateAccount} - shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={initiallyFocusedOptionKey} - listEmptyContent={listEmptyContent} - /> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + displayName={QuickbooksInvoiceAccountSelectPage.displayName} + sections={qboOnlineSelectorOptions.length ? [{data: qboOnlineSelectorOptions}] : []} + listItem={RadioListItem} + headerContent={listHeaderComponent} + onSelectRow={updateAccount} + shouldSingleExecuteRowSelect + initiallyFocusedOptionKey={initiallyFocusedOptionKey} + listEmptyContent={listEmptyContent} + title="workspace.qbo.advancedConfig.qboInvoiceCollectionAccount" + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID))} + pendingAction={settingsPendingAction([CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID)} + errorRowStyles={[styles.ph5, styles.mv3]} + onClose={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID)} + /> ); } diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage.tsx index 01867a6c764d..8d59c13e528a 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage.tsx @@ -1,19 +1,18 @@ import React from 'react'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Connections from '@libs/actions/connections'; import * as ConnectionUtils from '@libs/ConnectionUtils'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -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 {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -21,90 +20,97 @@ function QuickbooksCompanyCardExpenseAccountPage({policy}: WithPolicyConnections const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; - const {nonReimbursableBillDefaultVendor, autoCreateVendor, errorFields, pendingFields, nonReimbursableExpensesExportDestination, nonReimbursableExpensesAccount} = - policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; const {vendors} = policy?.connections?.quickbooksOnline?.data ?? {}; - const nonReimbursableBillDefaultVendorObject = vendors?.find((vendor) => vendor.id === nonReimbursableBillDefaultVendor); + const nonReimbursableBillDefaultVendorObject = vendors?.find((vendor) => vendor.id === qboConfig?.nonReimbursableBillDefaultVendor); + + const sections = [ + { + title: qboConfig?.nonReimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${qboConfig?.nonReimbursableExpensesExportDestination}`) : undefined, + description: translate('workspace.accounting.exportAs'), + onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_SELECT.getRoute(policyID)), + hintText: qboConfig?.nonReimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${qboConfig?.nonReimbursableExpensesExportDestination}Description`) : undefined, + subscribedSettings: [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSE_EXPORT_DESTINATION], + }, + { + title: qboConfig?.nonReimbursableExpensesAccount?.name ?? translate('workspace.qbo.notConfigured'), + description: ConnectionUtils.getQBONonReimbursableExportAccountType(qboConfig?.nonReimbursableExpensesExportDestination), + onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT.getRoute(policyID)), + subscribedSettings: [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSE_ACCOUNT], + }, + ]; + return ( - <AccessOrNotFoundWrapper + <ConnectionLayout policyID={policyID} + displayName={QuickbooksCompanyCardExpenseAccountPage.displayName} + headerTitle="workspace.accounting.exportCompanyCard" + title="workspace.qbo.exportCompanyCardsDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} + contentContainerStyle={styles.pb2} + titleStyle={styles.ph5} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.getRoute(policyID))} > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - testID={QuickbooksCompanyCardExpenseAccountPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.accounting.exportCompanyCard')} /> - <ScrollView contentContainerStyle={styles.pb2}> - <Text style={[styles.ph5, styles.pb5]}>{translate('workspace.qbo.exportCompanyCardsDescription')}</Text> - <OfflineWithFeedback pendingAction={pendingFields?.nonReimbursableExpensesExportDestination}> - <MenuItemWithTopDescription - title={nonReimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${nonReimbursableExpensesExportDestination}`) : undefined} - description={translate('workspace.accounting.exportAs')} - errorText={errorFields?.nonReimbursableExpensesExportDestination ? translate('common.genericErrorMessage') : undefined} - onPress={() => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_SELECT.getRoute(policyID))} - brickRoadIndicator={errorFields?.nonReimbursableExpensesExportDestination ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - shouldShowRightIcon - hintText={nonReimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${nonReimbursableExpensesExportDestination}Description`) : undefined} - /> - </OfflineWithFeedback> - <OfflineWithFeedback pendingAction={pendingFields?.nonReimbursableExpensesAccount}> - <MenuItemWithTopDescription - title={nonReimbursableExpensesAccount?.name} - description={ConnectionUtils.getQBONonReimbursableExportAccountType(nonReimbursableExpensesExportDestination)} - errorText={errorFields?.nonReimbursableExpensesAccount ? translate('common.genericErrorMessage') : undefined} - onPress={() => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT.getRoute(policyID))} - brickRoadIndicator={errorFields?.nonReimbursableExpensesAccount ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - shouldShowRightIcon - /> - </OfflineWithFeedback> - {nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL && ( - <> - <ToggleSettingOptionRow - shouldPlaceSubtitleBelowSwitch - subtitle={translate('workspace.qbo.defaultVendorDescription')} - switchAccessibilityLabel={translate('workspace.qbo.defaultVendorDescription')} - errors={errorFields?.autoCreateVendor ?? undefined} - title={translate('workspace.accounting.defaultVendor')} - wrapperStyle={[styles.ph5, styles.mb3, styles.mt1]} - isActive={!!autoCreateVendor} - onToggle={(isOn) => - Connections.updateManyPolicyConnectionConfigs( - policyID, - CONST.POLICY.CONNECTIONS.NAME.QBO, - { - [CONST.QUICK_BOOKS_CONFIG.AUTO_CREATE_VENDOR]: isOn, - [CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: isOn - ? policy?.connections?.quickbooksOnline?.data?.vendors?.[0]?.id ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE - : CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, - }, - { - [CONST.QUICK_BOOKS_CONFIG.AUTO_CREATE_VENDOR]: autoCreateVendor, - [CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: - nonReimbursableBillDefaultVendorObject?.id ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, - }, - ) + {sections.map((section) => ( + <OfflineWithFeedback pendingAction={PolicyUtils.settingsPendingAction(section.subscribedSettings, qboConfig?.pendingFields)}> + <MenuItemWithTopDescription + title={section.title} + description={section.description} + onPress={section.onPress} + brickRoadIndicator={PolicyUtils.areSettingsInErrorFields(section.subscribedSettings, qboConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + shouldShowRightIcon + hintText={section.hintText} + /> + </OfflineWithFeedback> + ))} + {qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL && ( + <> + <ToggleSettingOptionRow + title={translate('workspace.accounting.defaultVendor')} + subtitle={translate('workspace.qbo.defaultVendorDescription')} + switchAccessibilityLabel={translate('workspace.qbo.defaultVendorDescription')} + wrapperStyle={[styles.ph5, styles.mb3, styles.mt1]} + isActive={!!qboConfig?.autoCreateVendor} + pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR)} + onToggle={(isOn) => + Connections.updateManyPolicyConnectionConfigs( + policyID, + CONST.POLICY.CONNECTIONS.NAME.QBO, + { + [CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR]: isOn, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: isOn + ? policy?.connections?.quickbooksOnline?.data?.vendors?.[0]?.id ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE + : CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + }, + { + [CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR]: qboConfig?.autoCreateVendor, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: nonReimbursableBillDefaultVendorObject?.id ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + }, + ) + } + onCloseError={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR)} + /> + {qboConfig?.autoCreateVendor && ( + <OfflineWithFeedback pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR], qboConfig?.pendingFields)}> + <MenuItemWithTopDescription + title={nonReimbursableBillDefaultVendorObject?.name} + description={translate('workspace.accounting.defaultVendor')} + onPress={() => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT.getRoute(policyID))} + brickRoadIndicator={ + PolicyUtils.areSettingsInErrorFields([CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR], qboConfig?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined } - pendingAction={pendingFields?.autoCreateVendor} + shouldShowRightIcon /> - {autoCreateVendor && ( - <OfflineWithFeedback pendingAction={pendingFields?.nonReimbursableBillDefaultVendor}> - <MenuItemWithTopDescription - title={nonReimbursableBillDefaultVendorObject?.name} - description={translate('workspace.accounting.defaultVendor')} - onPress={() => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT.getRoute(policyID))} - brickRoadIndicator={errorFields?.nonReimbursableBillDefaultVendor ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - shouldShowRightIcon - errorText={errorFields?.nonReimbursableBillDefaultVendor ? translate('common.genericErrorMessage') : undefined} - /> - </OfflineWithFeedback> - )} - </> + </OfflineWithFeedback> )} - </ScrollView> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + </> + )} + </ConnectionLayout> ); } diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectCardPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectCardPage.tsx index c36f7df6b245..24a16f15ce2f 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectCardPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectCardPage.tsx @@ -1,23 +1,23 @@ import React, {useCallback, useMemo} from 'react'; -import {View} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import type {SelectorType} from '@components/SelectionScreen'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Connections from '@libs/actions/connections'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Account, QBONonReimbursableExportAccountType} from '@src/types/onyx/Policy'; -type AccountListItem = ListItem & { +type MenuItem = ListItem & { value: QBONonReimbursableExportAccountType; accounts: Account[]; defaultVendor: string; @@ -27,17 +27,17 @@ function QuickbooksCompanyCardExpenseAccountSelectCardPage({policy}: WithPolicyC const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; - const {nonReimbursableExpensesExportDestination, nonReimbursableExpensesAccount, syncLocations, nonReimbursableBillDefaultVendor} = policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; const {creditCards, bankAccounts, accountPayable, vendors} = policy?.connections?.quickbooksOnline?.data ?? {}; - const isLocationEnabled = !!(syncLocations && syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); + const isLocationEnabled = !!(qboConfig?.syncLocations && qboConfig?.syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); const sections = useMemo(() => { - const options: AccountListItem[] = [ + const options: MenuItem[] = [ { text: translate(`workspace.qbo.accounts.credit_card`), value: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, keyForList: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, - isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD === nonReimbursableExpensesExportDestination, + isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD === qboConfig?.nonReimbursableExpensesExportDestination, accounts: creditCards ?? [], defaultVendor: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, }, @@ -45,7 +45,7 @@ function QuickbooksCompanyCardExpenseAccountSelectCardPage({policy}: WithPolicyC text: translate(`workspace.qbo.accounts.debit_card`), value: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, keyForList: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, - isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD === nonReimbursableExpensesExportDestination, + isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD === qboConfig?.nonReimbursableExpensesExportDestination, accounts: bankAccounts ?? [], defaultVendor: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, }, @@ -55,60 +55,58 @@ function QuickbooksCompanyCardExpenseAccountSelectCardPage({policy}: WithPolicyC text: translate(`workspace.qbo.accounts.bill`), value: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, keyForList: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, - isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL === nonReimbursableExpensesExportDestination, + isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL === qboConfig?.nonReimbursableExpensesExportDestination, accounts: accountPayable ?? [], defaultVendor: vendors?.[0]?.id ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, }); } return [{data: options}]; - }, [translate, nonReimbursableExpensesExportDestination, isLocationEnabled, accountPayable, bankAccounts, creditCards, vendors]); + }, [translate, qboConfig?.nonReimbursableExpensesExportDestination, isLocationEnabled, accountPayable, bankAccounts, creditCards, vendors]); const selectExportCompanyCard = useCallback( - (row: AccountListItem) => { - if (row.value !== nonReimbursableExpensesExportDestination) { + (row: MenuItem) => { + if (row.value !== qboConfig?.nonReimbursableExpensesExportDestination) { Connections.updateManyPolicyConnectionConfigs( policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, { - [CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: row.value, - [CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT]: row.accounts[0], - [CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: row.defaultVendor, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: row.value, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT]: row.accounts[0], + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: row.defaultVendor, }, { - [CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: nonReimbursableExpensesExportDestination, - [CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT]: nonReimbursableExpensesAccount, - [CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: nonReimbursableBillDefaultVendor, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: qboConfig?.nonReimbursableExpensesExportDestination, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT]: qboConfig?.nonReimbursableExpensesAccount, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: qboConfig?.nonReimbursableBillDefaultVendor, }, ); } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT.getRoute(policyID)); }, - [nonReimbursableExpensesExportDestination, policyID, nonReimbursableExpensesAccount, nonReimbursableBillDefaultVendor], + [qboConfig?.nonReimbursableExpensesExportDestination, policyID, qboConfig?.nonReimbursableExpensesAccount, qboConfig?.nonReimbursableBillDefaultVendor], ); - return ( - <AccessOrNotFoundWrapper + <SelectionScreen policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} - > - <ScreenWrapper testID={QuickbooksCompanyCardExpenseAccountSelectCardPage.displayName}> - <HeaderWithBackButton title={translate('workspace.accounting.exportAs')} /> - <View style={styles.flex1}> - <SelectionList - containerStyle={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]} - sections={sections} - ListItem={RadioListItem} - onSelectRow={selectExportCompanyCard} - shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={sections[0].data.find((option) => option.isSelected)?.keyForList} - footerContent={ - isLocationEnabled && <Text style={[styles.mutedNormalTextLabel, styles.pt2]}>{translate('workspace.qbo.companyCardsLocationEnabledDescription')}</Text> - } - /> - </View> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + displayName={QuickbooksCompanyCardExpenseAccountSelectCardPage.displayName} + title="workspace.accounting.exportAs" + sections={sections} + listItem={RadioListItem} + onSelectRow={(selection: SelectorType) => selectExportCompanyCard(selection as MenuItem)} + shouldSingleExecuteRowSelect + initiallyFocusedOptionKey={sections[0]?.data.find((mode) => mode.isSelected)?.keyForList} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT.getRoute(policyID))} + listFooterContent={ + isLocationEnabled ? <Text style={[styles.mutedNormalTextLabel, styles.ph5, styles.pv3]}>{translate('workspace.qbo.companyCardsLocationEnabledDescription')}</Text> : undefined + } + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION)} + errorRowStyles={[styles.ph5, styles.pv3]} + pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION], qboConfig?.pendingFields)} + onClose={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION)} + /> ); } diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectPage.tsx index 8bee7d206180..5506be9c1ab0 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectPage.tsx @@ -1,21 +1,21 @@ import React, {useCallback, useMemo} from 'react'; import BlockingView from '@components/BlockingViews/BlockingView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Illustrations from '@components/Icon/Illustrations'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; 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 * as Connections from '@libs/actions/connections'; import * as ConnectionUtils from '@libs/ConnectionUtils'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import variables from '@styles/variables'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Account} from '@src/types/onyx/Policy'; @@ -29,12 +29,11 @@ function QuickbooksCompanyCardExpenseAccountSelectPage({policy}: WithPolicyConne const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; const {creditCards, accountPayable, bankAccounts} = policy?.connections?.quickbooksOnline?.data ?? {}; - - const {nonReimbursableExpensesAccount, nonReimbursableExpensesExportDestination} = policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; const data: CardListItem[] = useMemo(() => { let accounts: Account[]; - switch (nonReimbursableExpensesExportDestination) { + switch (qboConfig?.nonReimbursableExpensesExportDestination) { case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD: accounts = creditCards ?? []; break; @@ -52,18 +51,24 @@ function QuickbooksCompanyCardExpenseAccountSelectPage({policy}: WithPolicyConne value: card, text: card.name, keyForList: card.name, - isSelected: card.name === nonReimbursableExpensesAccount?.name, + isSelected: card.name === qboConfig?.nonReimbursableExpensesAccount?.name, })); - }, [nonReimbursableExpensesAccount, creditCards, bankAccounts, nonReimbursableExpensesExportDestination, accountPayable]); + }, [qboConfig?.nonReimbursableExpensesAccount, creditCards, bankAccounts, qboConfig?.nonReimbursableExpensesExportDestination, accountPayable]); const selectExportAccount = useCallback( (row: CardListItem) => { - if (row.value.id !== nonReimbursableExpensesAccount?.id) { - Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICK_BOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT, row.value); + if (row.value.id !== qboConfig?.nonReimbursableExpensesAccount?.id) { + Connections.updatePolicyConnectionConfig( + policyID, + CONST.POLICY.CONNECTIONS.NAME.QBO, + CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT, + row.value, + qboConfig?.nonReimbursableExpensesAccount, + ); } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT.getRoute(policyID)); }, - [nonReimbursableExpensesAccount, policyID], + [qboConfig?.nonReimbursableExpensesAccount, policyID], ); const listEmptyContent = useMemo( @@ -79,30 +84,31 @@ function QuickbooksCompanyCardExpenseAccountSelectPage({policy}: WithPolicyConne ), [translate, styles.pb10], ); - return ( - <AccessOrNotFoundWrapper + <SelectionScreen policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} - > - <ScreenWrapper testID={QuickbooksCompanyCardExpenseAccountSelectPage.displayName}> - <HeaderWithBackButton title={ConnectionUtils.getQBONonReimbursableExportAccountType(nonReimbursableExpensesExportDestination)} /> - <SelectionList - headerContent={ - nonReimbursableExpensesExportDestination ? ( - <Text style={[styles.ph5, styles.pb5]}>{translate(`workspace.qbo.accounts.${nonReimbursableExpensesExportDestination}AccountDescription`)}</Text> - ) : null - } - sections={data.length ? [{data}] : []} - ListItem={RadioListItem} - onSelectRow={selectExportAccount} - shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} - listEmptyContent={listEmptyContent} - /> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + displayName={QuickbooksCompanyCardExpenseAccountSelectPage.displayName} + headerTitleAlreadyTranslated={ConnectionUtils.getQBONonReimbursableExportAccountType(qboConfig?.nonReimbursableExpensesExportDestination)} + headerContent={ + qboConfig?.nonReimbursableExpensesExportDestination ? ( + <Text style={[styles.ph5, styles.pb5]}>{translate(`workspace.qbo.accounts.${qboConfig?.nonReimbursableExpensesExportDestination}AccountDescription`)}</Text> + ) : null + } + sections={data.length ? [{data}] : []} + listItem={RadioListItem} + onSelectRow={selectExportAccount} + shouldSingleExecuteRowSelect + initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} + listEmptyContent={listEmptyContent} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT.getRoute(policyID))} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT)} + errorRowStyles={[styles.ph5, styles.pv3]} + pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT], qboConfig?.pendingFields)} + onClose={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT)} + /> ); } diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksExportConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksExportConfigurationPage.tsx index b60431661f34..318582519604 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksExportConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksExportConfigurationPage.tsx @@ -1,79 +1,67 @@ -import React from 'react'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import type {MenuItemProps} from '@components/MenuItem'; +import React, {useMemo} from 'react'; +import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import * as Link from '@userActions/Link'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -type MenuItem = MenuItemProps & {pendingAction?: OfflineWithFeedbackProps['pendingAction']}; - function QuickbooksExportConfigurationPage({policy}: WithPolicyConnectionsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; const policyOwner = policy?.owner ?? ''; - const { - export: exportConfiguration, - exportDate, - reimbursableExpensesExportDestination, - receivableAccount, - nonReimbursableExpensesExportDestination, - errorFields, - pendingFields, - } = policy?.connections?.quickbooksOnline?.config ?? {}; - const menuItems: MenuItem[] = [ + const qboConfig = policy?.connections?.quickbooksOnline?.config; + const errorFields = qboConfig?.errorFields; + + const shouldShowVendorMenuItems = useMemo( + () => qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, + [qboConfig?.nonReimbursableExpensesExportDestination], + ); + const menuItems = [ { description: translate('workspace.accounting.preferredExporter'), onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_PREFERRED_EXPORTER.getRoute(policyID)), - brickRoadIndicator: errorFields?.exporter ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - title: exportConfiguration?.exporter ?? policyOwner, - pendingAction: pendingFields?.export, - errorText: errorFields?.exporter ? translate('common.genericErrorMessage') : undefined, + title: qboConfig?.export?.exporter ?? policyOwner, + subscribedSettings: [CONST.QUICKBOOKS_CONFIG.EXPORTER], }, { description: translate('workspace.qbo.date'), onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT.getRoute(policyID)), - brickRoadIndicator: errorFields?.exportDate ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - title: exportDate ? translate(`workspace.qbo.exportDate.values.${exportDate}.label`) : undefined, - pendingAction: pendingFields?.exportDate, - errorText: errorFields?.exportDate ? translate('common.genericErrorMessage') : undefined, + title: qboConfig?.exportDate ? translate(`workspace.qbo.exportDate.values.${qboConfig?.exportDate}.label`) : undefined, + subscribedSettings: [CONST.QUICKBOOKS_CONFIG.EXPORT_DATE], }, { description: translate('workspace.accounting.exportOutOfPocket'), onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES.getRoute(policyID)), - brickRoadIndicator: !!errorFields?.exportEntity || !!errorFields?.reimbursableExpensesAccount ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - title: reimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${reimbursableExpensesExportDestination}`) : undefined, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - pendingAction: pendingFields?.reimbursableExpensesExportDestination || pendingFields?.reimbursableExpensesAccount, + title: qboConfig?.reimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${qboConfig?.reimbursableExpensesExportDestination}`) : undefined, + subscribedSettings: [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION, CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT], }, { description: translate('workspace.qbo.exportInvoices'), onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECT.getRoute(policyID)), - brickRoadIndicator: errorFields?.receivableAccount ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - title: receivableAccount?.name, - pendingAction: pendingFields?.receivableAccount, - errorText: errorFields?.receivableAccount ? translate('common.genericErrorMessage') : undefined, + title: qboConfig?.receivableAccount?.name, + subscribedSettings: [CONST.QUICKBOOKS_CONFIG.RECEIVABLE_ACCOUNT], }, { description: translate('workspace.accounting.exportCompanyCard'), onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT.getRoute(policyID)), - brickRoadIndicator: errorFields?.exportCompanyCard ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - title: nonReimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${nonReimbursableExpensesExportDestination}`) : undefined, - pendingAction: pendingFields?.nonReimbursableExpensesExportDestination, - errorText: errorFields?.nonReimbursableExpensesExportDestination ? translate('common.genericErrorMessage') : undefined, + brickRoadIndicator: qboConfig?.errorFields?.exportCompanyCard ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + title: qboConfig?.nonReimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${qboConfig?.nonReimbursableExpensesExportDestination}`) : undefined, + subscribedSettings: [ + CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION, + CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSE_ACCOUNT, + ...(shouldShowVendorMenuItems ? [CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR] : []), + ...(shouldShowVendorMenuItems && qboConfig?.autoCreateVendor ? [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR] : []), + ], }, { description: translate('workspace.qbo.exportExpensifyCard'), @@ -84,46 +72,43 @@ function QuickbooksExportConfigurationPage({policy}: WithPolicyConnectionsProps) ]; return ( - <AccessOrNotFoundWrapper - policyID={policyID} + <ConnectionLayout + displayName={QuickbooksExportConfigurationPage.displayName} + headerTitle="workspace.accounting.export" + title="workspace.qbo.exportDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} + policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} + contentContainerStyle={styles.pb2} + titleStyle={styles.ph5} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))} > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - testID={QuickbooksExportConfigurationPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.accounting.export')} /> - <ScrollView contentContainerStyle={styles.pb2}> - <Text style={[styles.ph5, styles.pb5]}>{translate('workspace.qbo.exportDescription')}</Text> - {menuItems.map((menuItem) => ( - <OfflineWithFeedback - key={menuItem.description} - pendingAction={menuItem.pendingAction} - > - <MenuItemWithTopDescription - title={menuItem.title} - interactive={menuItem?.interactive ?? true} - description={menuItem.description} - shouldShowRightIcon={menuItem?.shouldShowRightIcon ?? true} - onPress={menuItem?.onPress} - brickRoadIndicator={menuItem?.brickRoadIndicator} - errorText={menuItem?.errorText} - /> - </OfflineWithFeedback> - ))} - <Text style={[styles.mutedNormalTextLabel, styles.ph5, styles.pb5, styles.mt2]}> - <Text style={[styles.mutedNormalTextLabel]}>{`${translate('workspace.qbo.deepDiveExpensifyCard')} `}</Text> - <TextLink - onPress={() => Link.openExternalLink(CONST.DEEP_DIVE_EXPENSIFY_CARD)} - style={[styles.mutedNormalTextLabel, styles.link]} - > - {translate('workspace.qbo.deepDiveExpensifyCardIntegration')} - </TextLink> - </Text> - </ScrollView> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + {menuItems.map((menuItem) => ( + <OfflineWithFeedback + key={menuItem.description} + pendingAction={PolicyUtils.settingsPendingAction(menuItem?.subscribedSettings, qboConfig?.pendingFields)} + > + <MenuItemWithTopDescription + title={menuItem.title} + interactive={menuItem?.interactive ?? true} + description={menuItem.description} + shouldShowRightIcon={menuItem?.shouldShowRightIcon ?? true} + onPress={menuItem?.onPress} + brickRoadIndicator={PolicyUtils.areSettingsInErrorFields(menuItem?.subscribedSettings, errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> + </OfflineWithFeedback> + ))} + <Text style={[styles.mutedNormalTextLabel, styles.ph5, styles.pb5, styles.mt2]}> + <Text style={[styles.mutedNormalTextLabel]}>{`${translate('workspace.qbo.deepDiveExpensifyCard')} `}</Text> + <TextLink + onPress={() => Link.openExternalLink(CONST.DEEP_DIVE_EXPENSIFY_CARD)} + style={[styles.mutedNormalTextLabel, styles.link]} + > + {translate('workspace.qbo.deepDiveExpensifyCardIntegration')} + </TextLink> + </Text> + </ConnectionLayout> ); } diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksExportDateSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksExportDateSelectPage.tsx index 89fbd6a96b33..a48a56b7710b 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksExportDateSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksExportDateSelectPage.tsx @@ -1,18 +1,18 @@ -import React, {useCallback} from 'react'; +import React, {useCallback, useMemo} from 'react'; import type {ValueOf} from 'type-fest'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; 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 * as Connections from '@libs/actions/connections'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -23,46 +23,47 @@ function QuickbooksExportDateSelectPage({policy}: WithPolicyConnectionsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; - const {exportDate} = policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; const data: CardListItem[] = Object.values(CONST.QUICKBOOKS_EXPORT_DATE).map((dateType) => ({ value: dateType, text: translate(`workspace.qbo.exportDate.values.${dateType}.label`), alternateText: translate(`workspace.qbo.exportDate.values.${dateType}.description`), keyForList: dateType, - isSelected: exportDate === dateType, + isSelected: qboConfig?.exportDate === dateType, })); + const exportDate = useMemo(() => qboConfig?.exportDate, [qboConfig]); + const selectExportDate = useCallback( (row: CardListItem) => { if (row.value !== exportDate) { - Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICK_BOOKS_CONFIG.EXPORT_DATE, row.value); + Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICKBOOKS_CONFIG.EXPORT_DATE, row.value, exportDate); } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT.getRoute(policyID)); }, - [exportDate, policyID], + [policyID, exportDate], ); return ( - <AccessOrNotFoundWrapper + <SelectionScreen policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} - > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - testID={QuickbooksExportDateSelectPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.qbo.exportDate.label')} /> - <SelectionList - headerContent={<Text style={[styles.ph5, styles.pb5]}>{translate('workspace.qbo.exportDate.description')}</Text>} - sections={[{data}]} - ListItem={RadioListItem} - onSelectRow={selectExportDate} - shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} - /> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + displayName={QuickbooksExportDateSelectPage.displayName} + sections={[{data}]} + listItem={RadioListItem} + headerContent={<Text style={[styles.ph5, styles.pb5]}>{translate('workspace.qbo.exportDate.description')}</Text>} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.getRoute(policyID))} + onSelectRow={selectExportDate} + initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} + title="workspace.qbo.exportDate.label" + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_CONFIG.EXPORT_DATE], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.EXPORT_DATE)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.EXPORT_DATE)} + shouldSingleExecuteRowSelect + /> ); } diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksExportInvoiceAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksExportInvoiceAccountSelectPage.tsx index e2b285fe3d41..69efae3e558b 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksExportInvoiceAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksExportInvoiceAccountSelectPage.tsx @@ -1,20 +1,20 @@ import React, {useCallback, useMemo} from 'react'; import BlockingView from '@components/BlockingViews/BlockingView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Illustrations from '@components/Icon/Illustrations'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; 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 * as Connections from '@libs/actions/connections'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import variables from '@styles/variables'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Account} from '@src/types/onyx/Policy'; @@ -27,7 +27,7 @@ function QuickbooksExportInvoiceAccountSelectPage({policy}: WithPolicyConnection const {translate} = useLocalize(); const styles = useThemeStyles(); const {accountsReceivable} = policy?.connections?.quickbooksOnline?.data ?? {}; - const {receivableAccount} = policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; const policyID = policy?.id ?? '-1'; const data: CardListItem[] = useMemo( @@ -36,19 +36,19 @@ function QuickbooksExportInvoiceAccountSelectPage({policy}: WithPolicyConnection value: account, text: account.name, keyForList: account.name, - isSelected: account.id === receivableAccount?.id, + isSelected: account.id === qboConfig?.receivableAccount?.id, })) ?? [], - [receivableAccount, accountsReceivable], + [qboConfig?.receivableAccount, accountsReceivable], ); const selectExportInvoice = useCallback( (row: CardListItem) => { - if (row.value.id !== receivableAccount?.id) { - Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICK_BOOKS_CONFIG.RECEIVABLE_ACCOUNT, row.value); + if (row.value.id !== qboConfig?.receivableAccount?.id) { + Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICKBOOKS_CONFIG.RECEIVABLE_ACCOUNT, row.value, qboConfig?.receivableAccount); } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECT.getRoute(policyID)); }, - [receivableAccount, policyID], + [qboConfig?.receivableAccount, policyID], ); const listEmptyContent = useMemo( @@ -66,24 +66,26 @@ function QuickbooksExportInvoiceAccountSelectPage({policy}: WithPolicyConnection ); return ( - <AccessOrNotFoundWrapper + <SelectionScreen policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} - > - <ScreenWrapper testID={QuickbooksExportInvoiceAccountSelectPage.displayName}> - <HeaderWithBackButton title={translate('workspace.qbo.exportInvoices')} /> - <SelectionList - headerContent={<Text style={[styles.ph5, styles.pb5]}>{translate('workspace.qbo.exportInvoicesDescription')}</Text>} - sections={data.length ? [{data}] : []} - ListItem={RadioListItem} - onSelectRow={selectExportInvoice} - shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} - listEmptyContent={listEmptyContent} - /> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + displayName={QuickbooksExportInvoiceAccountSelectPage.displayName} + sections={data.length ? [{data}] : []} + listItem={RadioListItem} + headerContent={<Text style={[styles.ph5, styles.pb5]}>{translate('workspace.qbo.exportInvoicesDescription')}</Text>} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.getRoute(policyID))} + onSelectRow={selectExportInvoice} + initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} + title="workspace.qbo.exportInvoices" + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_CONFIG.RECEIVABLE_ACCOUNT], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.RECEIVABLE_ACCOUNT)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.RECEIVABLE_ACCOUNT)} + listEmptyContent={listEmptyContent} + shouldSingleExecuteRowSelect + /> ); } diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableDefaultVendorSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableDefaultVendorSelectPage.tsx index 368cecc5de3b..95fe2793af2b 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableDefaultVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableDefaultVendorSelectPage.tsx @@ -1,19 +1,19 @@ import React, {useCallback, useMemo} from 'react'; import BlockingView from '@components/BlockingViews/BlockingView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Illustrations from '@components/Icon/Illustrations'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import variables from '@styles/variables'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -25,7 +25,7 @@ function QuickbooksNonReimbursableDefaultVendorSelectPage({policy}: WithPolicyCo const {translate} = useLocalize(); const styles = useThemeStyles(); const {vendors} = policy?.connections?.quickbooksOnline?.data ?? {}; - const {nonReimbursableBillDefaultVendor} = policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; const policyID = policy?.id ?? '-1'; const sections = useMemo(() => { @@ -34,19 +34,19 @@ function QuickbooksNonReimbursableDefaultVendorSelectPage({policy}: WithPolicyCo value: vendor.id, text: vendor.name, keyForList: vendor.name, - isSelected: vendor.id === nonReimbursableBillDefaultVendor, + isSelected: vendor.id === qboConfig?.nonReimbursableBillDefaultVendor, })) ?? []; return data.length ? [{data}] : []; - }, [nonReimbursableBillDefaultVendor, vendors]); + }, [qboConfig?.nonReimbursableBillDefaultVendor, vendors]); const selectVendor = useCallback( (row: CardListItem) => { - if (row.value !== nonReimbursableBillDefaultVendor) { - QuickbooksOnline.updateQuickbooksOnlineNonReimbursableBillDefaultVendor(policyID, row.value); + if (row.value !== qboConfig?.nonReimbursableBillDefaultVendor) { + QuickbooksOnline.updateQuickbooksOnlineNonReimbursableBillDefaultVendor(policyID, row.value, qboConfig?.nonReimbursableBillDefaultVendor); } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT.getRoute(policyID)); }, - [nonReimbursableBillDefaultVendor, policyID], + [qboConfig?.nonReimbursableBillDefaultVendor, policyID], ); const listEmptyContent = useMemo( @@ -64,23 +64,25 @@ function QuickbooksNonReimbursableDefaultVendorSelectPage({policy}: WithPolicyCo ); return ( - <AccessOrNotFoundWrapper + <SelectionScreen policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} - > - <ScreenWrapper testID={QuickbooksNonReimbursableDefaultVendorSelectPage.displayName}> - <HeaderWithBackButton title={translate('workspace.accounting.defaultVendor')} /> - <SelectionList - sections={sections} - ListItem={RadioListItem} - onSelectRow={selectVendor} - shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={sections[0]?.data.find((mode) => mode.isSelected)?.keyForList} - listEmptyContent={listEmptyContent} - /> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + displayName={QuickbooksNonReimbursableDefaultVendorSelectPage.displayName} + title="workspace.accounting.defaultVendor" + sections={sections} + listItem={RadioListItem} + onSelectRow={selectVendor} + shouldSingleExecuteRowSelect + initiallyFocusedOptionKey={sections[0]?.data.find((mode) => mode.isSelected)?.keyForList} + listEmptyContent={listEmptyContent} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT.getRoute(policyID))} + pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR)} + /> ); } diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseAccountSelectPage.tsx index 1e50464d3b06..6ca3b2c114b5 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseAccountSelectPage.tsx @@ -1,49 +1,48 @@ import React, {useCallback, useMemo} from 'react'; import BlockingView from '@components/BlockingViews/BlockingView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Illustrations from '@components/Icon/Illustrations'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; 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 * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import variables from '@styles/variables'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type {Account} from '@src/types/onyx/Policy'; type CardListItem = ListItem & { value: Account; }; - function QuickbooksOutOfPocketExpenseAccountSelectPage({policy}: WithPolicyConnectionsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {bankAccounts, journalEntryAccounts, accountPayable} = policy?.connections?.quickbooksOnline?.data ?? {}; - - const {reimbursableExpensesExportDestination, reimbursableExpensesAccount} = policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; const [title, description] = useMemo(() => { - let titleText: string | undefined; + let titleText: TranslationPaths | undefined; let descriptionText: string | undefined; - switch (reimbursableExpensesExportDestination) { + switch (qboConfig?.reimbursableExpensesExportDestination) { case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK: - titleText = translate('workspace.qbo.bankAccount'); + titleText = 'workspace.qbo.bankAccount'; descriptionText = translate('workspace.qbo.bankAccountDescription'); break; case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: - titleText = translate('workspace.qbo.account'); + titleText = 'workspace.qbo.account'; descriptionText = translate('workspace.qbo.accountDescription'); break; case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: - titleText = translate('workspace.qbo.accountsPayable'); + titleText = 'workspace.qbo.accountsPayable'; descriptionText = translate('workspace.qbo.accountsPayableDescription'); break; default: @@ -51,11 +50,11 @@ function QuickbooksOutOfPocketExpenseAccountSelectPage({policy}: WithPolicyConne } return [titleText, descriptionText]; - }, [translate, reimbursableExpensesExportDestination]); + }, [qboConfig?.reimbursableExpensesExportDestination, translate]); const data: CardListItem[] = useMemo(() => { let accounts: Account[]; - switch (reimbursableExpensesExportDestination) { + switch (qboConfig?.reimbursableExpensesExportDestination) { case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK: accounts = bankAccounts ?? []; break; @@ -73,20 +72,20 @@ function QuickbooksOutOfPocketExpenseAccountSelectPage({policy}: WithPolicyConne value: account, text: account.name, keyForList: account.name, - isSelected: account.id === reimbursableExpensesAccount?.id, + isSelected: account.id === qboConfig?.reimbursableExpensesAccount?.id, })); - }, [accountPayable, bankAccounts, reimbursableExpensesExportDestination, reimbursableExpensesAccount, journalEntryAccounts]); + }, [qboConfig?.reimbursableExpensesExportDestination, qboConfig?.reimbursableExpensesAccount?.id, bankAccounts, journalEntryAccounts, accountPayable]); const policyID = policy?.id ?? '-1'; const selectExportAccount = useCallback( (row: CardListItem) => { - if (row.value.id !== reimbursableExpensesAccount?.id) { - QuickbooksOnline.updateQuickbooksOnlineReimbursableExpensesAccount(policyID, row.value); + if (row.value.id !== qboConfig?.reimbursableExpensesAccount?.id) { + QuickbooksOnline.updateQuickbooksOnlineReimbursableExpensesAccount(policyID, row.value, qboConfig?.reimbursableExpensesAccount); } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES.getRoute(policyID)); }, - [reimbursableExpensesAccount, policyID], + [qboConfig?.reimbursableExpensesAccount, policyID], ); const listEmptyContent = useMemo( @@ -104,24 +103,26 @@ function QuickbooksOutOfPocketExpenseAccountSelectPage({policy}: WithPolicyConne ); return ( - <AccessOrNotFoundWrapper + <SelectionScreen policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} - > - <ScreenWrapper testID={QuickbooksOutOfPocketExpenseAccountSelectPage.displayName}> - <HeaderWithBackButton title={title} /> - <SelectionList - headerContent={<Text style={[styles.ph5, styles.pb5]}>{description}</Text>} - sections={data.length ? [{data}] : []} - ListItem={RadioListItem} - onSelectRow={selectExportAccount} - shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} - listEmptyContent={listEmptyContent} - /> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + displayName={QuickbooksOutOfPocketExpenseAccountSelectPage.displayName} + sections={data.length ? [{data}] : []} + listItem={RadioListItem} + headerContent={<Text style={[styles.ph5, styles.pb5]}>{description}</Text>} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES.getRoute(policyID))} + onSelectRow={selectExportAccount} + initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} + title={title} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT)} + listEmptyContent={listEmptyContent} + shouldSingleExecuteRowSelect + /> ); } diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseConfigurationPage.tsx index 679f021e60fa..772b33bb8872 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseConfigurationPage.tsx @@ -1,33 +1,42 @@ import React, {useMemo} from 'react'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {ValueOf} from 'type-fest'; +import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; 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 {PendingAction} from '@src/types/onyx/OnyxCommon'; + +type QBOSectionType = { + title?: string; + description?: string; + onPress: () => void; + errorText?: string; + hintText?: string; + subscribedSettings: string[]; + pendingAction?: PendingAction; + brickRoadIndicator?: ValueOf<typeof CONST.BRICK_ROAD_INDICATOR_STATUS>; +}; +const account = [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT]; +const accountOrExportDestination = [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION, CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT]; function QuickbooksOutOfPocketExpenseConfigurationPage({policy}: WithPolicyConnectionsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; - const {syncLocations, syncTax, reimbursableExpensesAccount, reimbursableExpensesExportDestination, errorFields, pendingFields} = policy?.connections?.quickbooksOnline?.config ?? {}; - const isLocationEnabled = !!(syncLocations && syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); - const isTaxesEnabled = !!syncTax; - const shouldShowTaxError = isTaxesEnabled && reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY; - const shouldShowLocationError = isLocationEnabled && reimbursableExpensesExportDestination !== CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY; - const hasErrors = !!errorFields?.reimbursableExpensesExportDestination || shouldShowTaxError || shouldShowLocationError; + const qboConfig = policy?.connections?.quickbooksOnline?.config; + const isLocationEnabled = !!(qboConfig?.syncLocations && qboConfig?.syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); + const isTaxesEnabled = !!qboConfig?.syncTax; const [exportHintText, accountDescription] = useMemo(() => { let hintText: string | undefined; let description: string | undefined; - switch (reimbursableExpensesExportDestination) { + switch (qboConfig?.reimbursableExpensesExportDestination) { case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK: hintText = isLocationEnabled ? undefined : translate('workspace.qbo.exportCheckDescription'); description = translate('workspace.qbo.bankAccount'); @@ -45,45 +54,58 @@ function QuickbooksOutOfPocketExpenseConfigurationPage({policy}: WithPolicyConne } return [hintText, description]; - }, [translate, reimbursableExpensesExportDestination, isLocationEnabled, isTaxesEnabled]); + }, [translate, qboConfig?.reimbursableExpensesExportDestination, isLocationEnabled, isTaxesEnabled]); + + const sections: QBOSectionType[] = [ + { + title: qboConfig?.reimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${qboConfig?.reimbursableExpensesExportDestination}`) : undefined, + description: translate('workspace.accounting.exportAs'), + onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT.getRoute(policyID)), + hintText: exportHintText, + subscribedSettings: accountOrExportDestination, + pendingAction: PolicyUtils.settingsPendingAction(accountOrExportDestination, qboConfig?.pendingFields), + brickRoadIndicator: PolicyUtils.areSettingsInErrorFields(accountOrExportDestination, qboConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + }, + { + title: qboConfig?.reimbursableExpensesAccount?.name ?? translate('workspace.qbo.notConfigured'), + description: accountDescription, + onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT.getRoute(policyID)), + subscribedSettings: account, + pendingAction: PolicyUtils.settingsPendingAction(account, qboConfig?.pendingFields), + brickRoadIndicator: PolicyUtils.areSettingsInErrorFields(account, qboConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + }, + ]; return ( - <AccessOrNotFoundWrapper - policyID={policyID} + <ConnectionLayout + displayName={QuickbooksOutOfPocketExpenseConfigurationPage.displayName} + headerTitle="workspace.accounting.exportOutOfPocket" + title="workspace.qbo.exportOutOfPocketExpensesDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} + policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} + contentContainerStyle={styles.pb2} + titleStyle={styles.ph5} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.getRoute(policyID))} > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - testID={QuickbooksOutOfPocketExpenseConfigurationPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.accounting.exportOutOfPocket')} /> - <ScrollView contentContainerStyle={styles.pb2}> - <Text style={[styles.ph5, styles.pb5]}>{translate('workspace.qbo.exportOutOfPocketExpensesDescription')}</Text> - <OfflineWithFeedback pendingAction={pendingFields?.reimbursableExpensesExportDestination}> - <MenuItemWithTopDescription - title={reimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${reimbursableExpensesExportDestination}`) : undefined} - description={translate('workspace.accounting.exportAs')} - errorText={hasErrors && reimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${reimbursableExpensesExportDestination}Error`) : undefined} - onPress={() => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT.getRoute(policyID))} - brickRoadIndicator={hasErrors ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - shouldShowRightIcon - hintText={exportHintText} - /> - </OfflineWithFeedback> - <OfflineWithFeedback pendingAction={pendingFields?.reimbursableExpensesAccount}> - <MenuItemWithTopDescription - title={reimbursableExpensesAccount?.name} - description={accountDescription} - onPress={() => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT.getRoute(policyID))} - brickRoadIndicator={errorFields?.exportAccount ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - shouldShowRightIcon - errorText={errorFields?.exportAccount ? translate('common.genericErrorMessage') : undefined} - /> - </OfflineWithFeedback> - </ScrollView> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + {sections.map((section, index) => ( + <OfflineWithFeedback + pendingAction={section.pendingAction} + // eslint-disable-next-line react/no-array-index-key + key={index} + > + <MenuItemWithTopDescription + title={section.title} + description={section.description} + onPress={section.onPress} + shouldShowRightIcon + brickRoadIndicator={section.brickRoadIndicator} + hintText={section.hintText} + /> + </OfflineWithFeedback> + ))} + </ConnectionLayout> ); } diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseEntitySelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseEntitySelectPage.tsx index b0d8afa6d53b..51dee308fb75 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseEntitySelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseEntitySelectPage.tsx @@ -1,19 +1,19 @@ import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; -import type {SectionListData} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; -import type {ListItem, Section} from '@components/SelectionList/types'; +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import type {SelectorType} from '@components/SelectionScreen'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Connections from '@libs/actions/connections'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Account, QBOReimbursableExportAccountType} from '@src/types/onyx/Policy'; @@ -27,36 +27,36 @@ function Footer({isTaxEnabled, isLocationsEnabled}: {isTaxEnabled: boolean; isLo } return ( - <View style={[styles.gap2, styles.mt2]}> + <View style={[styles.gap2, styles.mt2, styles.ph5]}> {isTaxEnabled && <Text style={styles.mutedNormalTextLabel}>{translate('workspace.qbo.outOfPocketTaxEnabledDescription')}</Text>} {isLocationsEnabled && <Text style={styles.mutedNormalTextLabel}>{translate('workspace.qbo.outOfPocketLocationEnabledDescription')}</Text>} </View> ); } - -type CardListItem = ListItem & { +type MenuItem = ListItem & { value: QBOReimbursableExportAccountType; isShown: boolean; accounts: Account[]; }; -type CardsSection = SectionListData<CardListItem, Section<CardListItem>>; - function QuickbooksOutOfPocketExpenseEntitySelectPage({policy}: WithPolicyConnectionsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {reimbursableExpensesExportDestination, reimbursableExpensesAccount, syncTax, syncLocations} = policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; const {bankAccounts, accountPayable, journalEntryAccounts} = policy?.connections?.quickbooksOnline?.data ?? {}; - const isLocationsEnabled = !!(syncLocations && syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); - const isTaxesEnabled = !!syncTax; + const isLocationsEnabled = !!(qboConfig?.syncLocations && qboConfig?.syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); + const isTaxesEnabled = !!qboConfig?.syncTax; + const shouldShowTaxError = isTaxesEnabled && qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY; + const shouldShowLocationError = isLocationsEnabled && qboConfig?.reimbursableExpensesExportDestination !== CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY; + const hasErrors = !!qboConfig?.errorFields?.reimbursableExpensesExportDestination && (shouldShowTaxError || shouldShowLocationError); const policyID = policy?.id ?? '-1'; - const data: CardListItem[] = useMemo( + const data: MenuItem[] = useMemo( () => [ { value: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK, text: translate(`workspace.qbo.accounts.check`), keyForList: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK, - isSelected: reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK, + isSelected: qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK, isShown: !isLocationsEnabled, accounts: bankAccounts ?? [], }, @@ -64,7 +64,7 @@ function QuickbooksOutOfPocketExpenseEntitySelectPage({policy}: WithPolicyConnec value: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, text: translate(`workspace.qbo.accounts.journal_entry`), keyForList: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, - isSelected: reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, + isSelected: qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, isShown: !isTaxesEnabled, accounts: journalEntryAccounts ?? [], }, @@ -72,66 +72,69 @@ function QuickbooksOutOfPocketExpenseEntitySelectPage({policy}: WithPolicyConnec value: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, text: translate(`workspace.qbo.accounts.bill`), keyForList: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - isSelected: reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + isSelected: qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, isShown: !isLocationsEnabled, accounts: accountPayable ?? [], }, ], - [reimbursableExpensesExportDestination, isTaxesEnabled, translate, isLocationsEnabled, bankAccounts, accountPayable, journalEntryAccounts], + [qboConfig?.reimbursableExpensesExportDestination, isTaxesEnabled, translate, isLocationsEnabled, bankAccounts, accountPayable, journalEntryAccounts], ); - const sections: CardsSection[] = useMemo(() => [{data: data.filter((item) => item.isShown)}], [data]); + const sections = useMemo(() => [{data: data.filter((item) => item.isShown)}], [data]); const selectExportEntity = useCallback( - (row: CardListItem) => { - if (row.value !== reimbursableExpensesExportDestination) { + (row: MenuItem) => { + if (row.value !== qboConfig?.reimbursableExpensesExportDestination) { Connections.updateManyPolicyConnectionConfigs( policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, { - [CONST.QUICK_BOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: row.value, - [CONST.QUICK_BOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT]: row.accounts[0], + [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: row.value, + [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT]: row.accounts[0], }, { - [CONST.QUICK_BOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: reimbursableExpensesExportDestination, - [CONST.QUICK_BOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT]: reimbursableExpensesAccount, + [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: qboConfig?.reimbursableExpensesExportDestination, + [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT]: qboConfig?.reimbursableExpensesAccount, }, ); } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES.getRoute(policyID)); }, - [reimbursableExpensesExportDestination, policyID, reimbursableExpensesAccount], + [qboConfig?.reimbursableExpensesExportDestination, policyID, qboConfig?.reimbursableExpensesAccount], ); return ( - <AccessOrNotFoundWrapper + <SelectionScreen policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} - > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - testID={QuickbooksOutOfPocketExpenseEntitySelectPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.accounting.exportAs')} /> - <View style={styles.flex1}> - <SelectionList - containerStyle={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]} - sections={sections} - ListItem={RadioListItem} - onSelectRow={selectExportEntity} - shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} - footerContent={ - <Footer - isTaxEnabled={isTaxesEnabled} - isLocationsEnabled={isLocationsEnabled} - /> - } - /> - </View> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + displayName={QuickbooksOutOfPocketExpenseEntitySelectPage.displayName} + sections={sections} + listItem={RadioListItem} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES.getRoute(policyID))} + onSelectRow={(selection: SelectorType) => selectExportEntity(selection as MenuItem)} + shouldSingleExecuteRowSelect + initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} + title="workspace.accounting.exportAs" + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + pendingAction={PolicyUtils.settingsPendingAction( + [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION, CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT], + qboConfig?.pendingFields, + )} + errors={ + hasErrors && qboConfig?.reimbursableExpensesExportDestination + ? {[CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: translate(`workspace.qbo.accounts.${qboConfig?.reimbursableExpensesExportDestination}Error`)} + : ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION) + } + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION)} + listFooterContent={ + <Footer + isTaxEnabled={isTaxesEnabled} + isLocationsEnabled={isLocationsEnabled} + /> + } + /> ); } diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksPreferredExporterConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksPreferredExporterConfigurationPage.tsx index 55d262ebb21e..9115dcb70eaf 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksPreferredExporterConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksPreferredExporterConfigurationPage.tsx @@ -1,20 +1,19 @@ import React, {useCallback, useMemo} from 'react'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; 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 * as Connections from '@libs/actions/connections'; +import * as ErrorUtils from '@libs/ErrorUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import {getAdminEmployees} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -25,7 +24,7 @@ type CardListItem = ListItem & { function QuickbooksPreferredExporterConfigurationPage({policy}: WithPolicyConnectionsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {export: exportConfiguration} = policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; const exporters = getAdminEmployees(policy); const {login: currentUserLogin} = useCurrentUserPersonalDetails(); @@ -45,46 +44,59 @@ function QuickbooksPreferredExporterConfigurationPage({policy}: WithPolicyConnec value: exporter.email, text: exporter.email, keyForList: exporter.email, - isSelected: (exportConfiguration?.exporter ?? policy?.owner) === exporter.email, + isSelected: (qboConfig?.export?.exporter ?? policy?.owner) === exporter.email, }); return options; }, []), - [exportConfiguration, exporters, currentUserLogin, policy?.owner], + [exporters, policy?.owner, currentUserLogin, qboConfig?.export?.exporter], ); const selectExporter = useCallback( (row: CardListItem) => { - if (row.value !== exportConfiguration?.exporter) { - Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICK_BOOKS_CONFIG.EXPORT, {exporter: row.value}); + if (row.value !== qboConfig?.export?.exporter) { + Connections.updatePolicyConnectionConfig( + policyID, + CONST.POLICY.CONNECTIONS.NAME.QBO, + CONST.QUICKBOOKS_CONFIG.EXPORT, + {exporter: row.value}, + {exporter: qboConfig?.export.exporter}, + ); } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_PREFERRED_EXPORTER.getRoute(policyID)); }, - [policyID, exportConfiguration], + [qboConfig?.export, policyID], + ); + + const headerContent = useMemo( + () => ( + <> + <Text style={[styles.ph5, styles.pb5]}>{translate('workspace.accounting.exportPreferredExporterNote')}</Text> + <Text style={[styles.ph5, styles.pb5]}>{translate('workspace.accounting.exportPreferredExporterSubNote')}</Text> + </> + ), + [translate, styles.ph5, styles.pb5], ); return ( - <AccessOrNotFoundWrapper + <SelectionScreen policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} - > - <ScreenWrapper testID={QuickbooksPreferredExporterConfigurationPage.displayName}> - <HeaderWithBackButton title={translate('workspace.accounting.preferredExporter')} /> - <SelectionList - headerContent={ - <> - <Text style={[styles.ph5, styles.pb5]}>{translate('workspace.accounting.exportPreferredExporterNote')}</Text> - <Text style={[styles.ph5, styles.pb5]}>{translate('workspace.accounting.exportPreferredExporterSubNote')}</Text> - </> - } - sections={[{data}]} - ListItem={RadioListItem} - onSelectRow={selectExporter} - shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} - /> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + displayName={QuickbooksPreferredExporterConfigurationPage.displayName} + sections={[{data}]} + listItem={RadioListItem} + headerContent={headerContent} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.getRoute(policyID))} + onSelectRow={selectExporter} + shouldSingleExecuteRowSelect + initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} + title="workspace.accounting.preferredExporter" + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_CONFIG.EXPORTER], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.EXPORTER)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.EXPORTER)} + /> ); } diff --git a/src/pages/workspace/accounting/qbo/import/QuickbooksChartOfAccountsPage.tsx b/src/pages/workspace/accounting/qbo/import/QuickbooksChartOfAccountsPage.tsx index 1ccf944dd9c6..73907cb16669 100644 --- a/src/pages/workspace/accounting/qbo/import/QuickbooksChartOfAccountsPage.tsx +++ b/src/pages/workspace/accounting/qbo/import/QuickbooksChartOfAccountsPage.tsx @@ -1,72 +1,65 @@ import React from 'react'; -import {View} from 'react-native'; import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import Switch from '@components/Switch'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; -import variables from '@styles/variables'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; function QuickbooksChartOfAccountsPage({policy}: WithPolicyProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; - const {enableNewCategories, pendingFields} = policy?.connections?.quickbooksOnline?.config ?? {}; + const qboConfig = policy?.connections?.quickbooksOnline?.config; return ( <ConnectionLayout + policyID={policyID} displayName={QuickbooksChartOfAccountsPage.displayName} headerTitle="workspace.accounting.accounts" title="workspace.qbo.accountsDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} - policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} contentContainerStyle={[styles.pb2, styles.ph5]} connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT.getRoute(policyID))} > - <View style={[styles.flexRow, styles.mb4, styles.alignItemsCenter, styles.justifyContentBetween]}> - <View style={styles.flex1}> - <Text fontSize={variables.fontSizeNormal}>{translate('workspace.accounting.import')}</Text> - </View> - <View style={[styles.flex1, styles.alignItemsEnd, styles.pl3]}> - <Switch - accessibilityLabel={translate('workspace.accounting.accounts')} - isOn - disabled - onToggle={() => {}} - /> - </View> - </View> + <ToggleSettingOptionRow + title={translate('workspace.accounting.import')} + switchAccessibilityLabel={translate('workspace.accounting.accounts')} + shouldPlaceSubtitleBelowSwitch + isActive + onToggle={() => {}} + disabled + showLockIcon + /> <MenuItemWithTopDescription interactive={false} title={translate('workspace.common.categories')} description={translate('workspace.common.displayedAs')} - wrapperStyle={styles.sectionMenuItemTopDescription} + wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt2]} /> <Text style={styles.pv5}>{translate('workspace.qbo.accountsSwitchTitle')}</Text> - <View style={[styles.flexRow, styles.mb2, styles.alignItemsCenter, styles.justifyContentBetween]}> - <View style={styles.flex1}> - <Text fontSize={variables.fontSizeNormal}>{translate('workspace.common.enabled')}</Text> - </View> - <OfflineWithFeedback pendingAction={pendingFields?.enableNewCategories}> - <View style={[styles.flex1, styles.alignItemsEnd, styles.pl3]}> - <Switch - accessibilityLabel={translate('workspace.accounting.accounts')} - isOn={!!enableNewCategories} - onToggle={() => QuickbooksOnline.updateQuickbooksOnlineEnableNewCategories(policyID, !enableNewCategories)} - /> - </View> - </OfflineWithFeedback> - </View> - <View style={styles.flex1}> - <Text style={styles.mutedTextLabel}>{translate('workspace.qbo.accountsSwitchDescription')}</Text> - </View> + <ToggleSettingOptionRow + title={translate('workspace.common.enabled')} + subtitle={translate('workspace.qbo.accountsSwitchDescription')} + switchAccessibilityLabel={translate('workspace.accounting.accounts')} + shouldPlaceSubtitleBelowSwitch + isActive={!!qboConfig?.enableNewCategories} + onToggle={() => QuickbooksOnline.updateQuickbooksOnlineEnableNewCategories(policyID, !qboConfig?.enableNewCategories)} + pendingAction={settingsPendingAction([CONST.QUICKBOOKS_CONFIG.ENABLE_NEW_CATEGORIES], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.ENABLE_NEW_CATEGORIES)} + onCloseError={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.ENABLE_NEW_CATEGORIES)} + /> </ConnectionLayout> ); } diff --git a/src/pages/workspace/accounting/qbo/import/QuickbooksClassesPage.tsx b/src/pages/workspace/accounting/qbo/import/QuickbooksClassesPage.tsx index af2d8da42216..70eb9bf8cdb5 100644 --- a/src/pages/workspace/accounting/qbo/import/QuickbooksClassesPage.tsx +++ b/src/pages/workspace/accounting/qbo/import/QuickbooksClassesPage.tsx @@ -1,75 +1,63 @@ import React from 'react'; -import {View} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Switch from '@components/Switch'; -import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; -import variables from '@styles/variables'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; function QuickbooksClassesPage({policy}: WithPolicyProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; - const {syncClasses, pendingFields} = policy?.connections?.quickbooksOnline?.config ?? {}; - const isSwitchOn = !!(syncClasses && syncClasses !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); - const isReportFieldsSelected = syncClasses === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD; + const qboConfig = policy?.connections?.quickbooksOnline?.config; + const isSwitchOn = !!(qboConfig?.syncClasses && qboConfig.syncClasses !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); + const isReportFieldsSelected = qboConfig?.syncClasses === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD; return ( - <AccessOrNotFoundWrapper + <ConnectionLayout + displayName={QuickbooksClassesPage.displayName} + headerTitle="workspace.qbo.classes" + title="workspace.qbo.classesDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} + contentContainerStyle={[styles.pb2, styles.ph5]} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT.getRoute(policyID))} > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - shouldEnableMaxHeight - testID={QuickbooksClassesPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.qbo.classes')} /> - <ScrollView contentContainerStyle={[styles.pb2, styles.ph5]}> - <Text style={styles.pb5}>{translate('workspace.qbo.classesDescription')}</Text> - <View style={[styles.flexRow, styles.mb4, styles.alignItemsCenter, styles.justifyContentBetween]}> - <View style={styles.flex1}> - <Text fontSize={variables.fontSizeNormal}>{translate('workspace.accounting.import')}</Text> - </View> - <OfflineWithFeedback pendingAction={pendingFields?.syncClasses}> - <View style={[styles.flex1, styles.alignItemsEnd, styles.pl3]}> - <Switch - accessibilityLabel={translate('workspace.qbo.classes')} - isOn={isSwitchOn} - onToggle={() => - QuickbooksOnline.updateQuickbooksOnlineSyncClasses( - policyID, - isSwitchOn ? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE : CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - ) - } - /> - </View> - </OfflineWithFeedback> - </View> - {isSwitchOn && ( - <OfflineWithFeedback> - <MenuItemWithTopDescription - interactive={false} - title={isReportFieldsSelected ? translate('workspace.common.reportFields') : translate('workspace.common.tags')} - description={translate('workspace.common.displayedAs')} - wrapperStyle={styles.sectionMenuItemTopDescription} - /> - </OfflineWithFeedback> - )} - </ScrollView> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + <ToggleSettingOptionRow + title={translate('workspace.accounting.import')} + switchAccessibilityLabel={translate('workspace.qbo.classes')} + isActive={isSwitchOn} + onToggle={() => + QuickbooksOnline.updateQuickbooksOnlineSyncClasses( + policyID, + isSwitchOn ? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE : CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + qboConfig?.syncClasses, + ) + } + pendingAction={settingsPendingAction([CONST.QUICKBOOKS_CONFIG.SYNC_CLASSES], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.SYNC_CLASSES)} + onCloseError={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.SYNC_CLASSES)} + /> + {isSwitchOn && ( + <MenuItemWithTopDescription + interactive={false} + title={isReportFieldsSelected ? translate('workspace.common.reportFields') : translate('workspace.common.tags')} + description={translate('workspace.common.displayedAs')} + wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt4]} + /> + )} + </ConnectionLayout> ); } diff --git a/src/pages/workspace/accounting/qbo/import/QuickbooksCustomersPage.tsx b/src/pages/workspace/accounting/qbo/import/QuickbooksCustomersPage.tsx index 949978492dc9..67a326858379 100644 --- a/src/pages/workspace/accounting/qbo/import/QuickbooksCustomersPage.tsx +++ b/src/pages/workspace/accounting/qbo/import/QuickbooksCustomersPage.tsx @@ -1,74 +1,62 @@ import React from 'react'; -import {View} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Switch from '@components/Switch'; -import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; -import variables from '@styles/variables'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; function QuickbooksCustomersPage({policy}: WithPolicyProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; - const {syncCustomers, pendingFields} = policy?.connections?.quickbooksOnline?.config ?? {}; - const isSwitchOn = !!(syncCustomers && syncCustomers !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); - const isReportFieldsSelected = syncCustomers === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD; + const qboConfig = policy?.connections?.quickbooksOnline?.config; + const isSwitchOn = !!(qboConfig?.syncCustomers && qboConfig?.syncCustomers !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); + const isReportFieldsSelected = qboConfig?.syncCustomers === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD; return ( - <AccessOrNotFoundWrapper + <ConnectionLayout + displayName={QuickbooksCustomersPage.displayName} + headerTitle="workspace.qbo.customers" + title="workspace.qbo.customersDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} + contentContainerStyle={[styles.pb2, styles.ph5]} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT.getRoute(policyID))} > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - shouldEnableMaxHeight - testID={QuickbooksCustomersPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.qbo.customers')} /> - <ScrollView contentContainerStyle={[styles.pb2, styles.ph5]}> - <Text style={styles.pb5}>{translate('workspace.qbo.customersDescription')}</Text> - <View style={[styles.flexRow, styles.mb4, styles.alignItemsCenter, styles.justifyContentBetween]}> - <View style={styles.flex1}> - <Text fontSize={variables.fontSizeNormal}>{translate('workspace.accounting.import')}</Text> - </View> - <OfflineWithFeedback pendingAction={pendingFields?.syncCustomers}> - <View style={[styles.flex1, styles.alignItemsEnd, styles.pl3]}> - <Switch - accessibilityLabel={translate('workspace.qbo.customers')} - isOn={isSwitchOn} - onToggle={() => - QuickbooksOnline.updateQuickbooksOnlineSyncCustomers( - policyID, - isSwitchOn ? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE : CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - ) - } - /> - </View> - </OfflineWithFeedback> - </View> - {isSwitchOn && ( - <OfflineWithFeedback> - <MenuItemWithTopDescription - interactive={false} - title={isReportFieldsSelected ? translate('workspace.common.reportFields') : translate('workspace.common.tags')} - description={translate('workspace.common.displayedAs')} - wrapperStyle={styles.sectionMenuItemTopDescription} - /> - </OfflineWithFeedback> - )} - </ScrollView> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + <ToggleSettingOptionRow + title={translate('workspace.accounting.import')} + switchAccessibilityLabel={translate('workspace.qbo.customers')} + isActive={isSwitchOn} + onToggle={() => + QuickbooksOnline.updateQuickbooksOnlineSyncCustomers( + policyID, + isSwitchOn ? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE : CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + qboConfig?.syncCustomers, + ) + } + pendingAction={settingsPendingAction([CONST.QUICKBOOKS_CONFIG.SYNC_CUSTOMERS], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.SYNC_CUSTOMERS)} + onCloseError={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.SYNC_CUSTOMERS)} + /> + {isSwitchOn && ( + <MenuItemWithTopDescription + interactive={false} + title={isReportFieldsSelected ? translate('workspace.common.reportFields') : translate('workspace.common.tags')} + description={translate('workspace.common.displayedAs')} + wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt4]} + /> + )} + </ConnectionLayout> ); } diff --git a/src/pages/workspace/accounting/qbo/import/QuickbooksImportPage.tsx b/src/pages/workspace/accounting/qbo/import/QuickbooksImportPage.tsx index 4ea1b5de427a..56089d9d25d5 100644 --- a/src/pages/workspace/accounting/qbo/import/QuickbooksImportPage.tsx +++ b/src/pages/workspace/accounting/qbo/import/QuickbooksImportPage.tsx @@ -1,53 +1,53 @@ import React from 'react'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +type QBOSectionType = { + description: string; + action: () => void; + title: string; + subscribedSettings: [string]; +}; + function QuickbooksImportPage({policy}: WithPolicyProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; - const {syncClasses, syncCustomers, syncLocations, syncTax, pendingFields} = policy?.connections?.quickbooksOnline?.config ?? {}; + const {syncClasses, syncCustomers, syncLocations, syncTax, pendingFields, errorFields} = policy?.connections?.quickbooksOnline?.config ?? {}; - const sections = [ + const sections: QBOSectionType[] = [ { description: translate('workspace.accounting.accounts'), action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS.getRoute(policyID)), - hasError: !!policy?.errors?.enableNewCategories, title: translate('workspace.accounting.importAsCategory'), - pendingAction: pendingFields?.enableNewCategories, + subscribedSettings: [CONST.QUICKBOOKS_CONFIG.ENABLE_NEW_CATEGORIES], }, { description: translate('workspace.qbo.classes'), action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CLASSES.getRoute(policyID)), - hasError: !!policy?.errors?.syncClasses, title: translate(`workspace.accounting.importTypes.${syncClasses ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE}`), - pendingAction: pendingFields?.syncClasses, + subscribedSettings: [CONST.QUICKBOOKS_CONFIG.SYNC_CLASSES], }, { description: translate('workspace.qbo.customers'), action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CUSTOMERS.getRoute(policyID)), - hasError: !!policy?.errors?.syncCustomers, title: translate(`workspace.accounting.importTypes.${syncCustomers ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE}`), - pendingAction: pendingFields?.syncCustomers, + subscribedSettings: [CONST.QUICKBOOKS_CONFIG.SYNC_CUSTOMERS], }, { description: translate('workspace.qbo.locations'), action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_LOCATIONS.getRoute(policyID)), - hasError: !!policy?.errors?.syncLocations, title: translate(`workspace.accounting.importTypes.${syncLocations ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE}`), - pendingAction: pendingFields?.syncLocations, + subscribedSettings: [CONST.QUICKBOOKS_CONFIG.SYNC_LOCATIONS], }, ]; @@ -55,43 +55,39 @@ function QuickbooksImportPage({policy}: WithPolicyProps) { sections.push({ description: translate('workspace.accounting.taxes'), action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_TAXES.getRoute(policyID)), - hasError: !!policy?.errors?.syncTax, title: translate(syncTax ? 'workspace.accounting.imported' : 'workspace.accounting.notImported'), - pendingAction: pendingFields?.syncTax, + subscribedSettings: [CONST.QUICKBOOKS_CONFIG.SYNC_TAX], }); } return ( - <AccessOrNotFoundWrapper + <ConnectionLayout + displayName={QuickbooksImportPage.displayName} + headerTitle="workspace.accounting.import" + title="workspace.qbo.importDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} + contentContainerStyle={styles.pb2} + titleStyle={styles.ph5} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))} > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - shouldEnableMaxHeight - testID={QuickbooksImportPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.accounting.import')} /> - <ScrollView contentContainerStyle={styles.pb2}> - <Text style={[styles.ph5, styles.pb5]}>{translate('workspace.qbo.importDescription')}</Text> - {sections.map((section) => ( - <OfflineWithFeedback - key={section.description} - pendingAction={section.pendingAction} - > - <MenuItemWithTopDescription - title={section.title} - description={section.description} - shouldShowRightIcon - onPress={section.action} - brickRoadIndicator={section.hasError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - /> - </OfflineWithFeedback> - ))} - </ScrollView> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + {sections.map((section) => ( + <OfflineWithFeedback + key={section.description} + pendingAction={PolicyUtils.settingsPendingAction(section.subscribedSettings, pendingFields)} + > + <MenuItemWithTopDescription + title={section.title} + description={section.description} + shouldShowRightIcon + onPress={section.action} + brickRoadIndicator={PolicyUtils.areSettingsInErrorFields(section.subscribedSettings, errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> + </OfflineWithFeedback> + ))} + </ConnectionLayout> ); } diff --git a/src/pages/workspace/accounting/qbo/import/QuickbooksLocationsPage.tsx b/src/pages/workspace/accounting/qbo/import/QuickbooksLocationsPage.tsx index 5368d2ff8fb2..f619dfc2772e 100644 --- a/src/pages/workspace/accounting/qbo/import/QuickbooksLocationsPage.tsx +++ b/src/pages/workspace/accounting/qbo/import/QuickbooksLocationsPage.tsx @@ -1,85 +1,75 @@ import React from 'react'; import {View} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Switch from '@components/Switch'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; -import variables from '@styles/variables'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; function QuickbooksLocationsPage({policy}: WithPolicyProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; - const {syncLocations, pendingFields, reimbursableExpensesExportDestination, nonReimbursableExpensesExportDestination} = policy?.connections?.quickbooksOnline?.config ?? {}; - const isSwitchOn = !!(syncLocations && syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); + const qboConfig = policy?.connections?.quickbooksOnline?.config; + const isSwitchOn = !!(qboConfig?.syncLocations && qboConfig?.syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); const canImportLocation = - reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY && - nonReimbursableExpensesExportDestination !== CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL; + qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY && + qboConfig?.nonReimbursableExpensesExportDestination !== CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL; const shouldBeDisabled = !canImportLocation && !isSwitchOn; - const isReportFieldsSelected = syncLocations === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD; + const isReportFieldsSelected = qboConfig?.syncLocations === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD; return ( - <AccessOrNotFoundWrapper + <ConnectionLayout + displayName={QuickbooksLocationsPage.displayName} + headerTitle="workspace.qbo.locations" + title="workspace.qbo.locationsDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} + contentContainerStyle={[[styles.pb2, styles.ph5]]} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT.getRoute(policyID))} > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - shouldEnableMaxHeight - testID={QuickbooksLocationsPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.qbo.locations')} /> - <ScrollView contentContainerStyle={[styles.pb2, styles.ph5]}> - <Text style={styles.pb5}>{translate('workspace.qbo.locationsDescription')}</Text> - <View style={[styles.flexRow, styles.mb4, styles.alignItemsCenter, styles.justifyContentBetween]}> - <View style={styles.flex1}> - <Text fontSize={variables.fontSizeNormal}>{translate('workspace.accounting.import')}</Text> - </View> - <OfflineWithFeedback pendingAction={pendingFields?.syncLocations}> - <View style={[styles.flex1, styles.alignItemsEnd, styles.pl3]}> - <Switch - accessibilityLabel={translate('workspace.qbo.locations')} - isOn={isSwitchOn} - onToggle={() => - QuickbooksOnline.updateQuickbooksOnlineSyncLocations( - policyID, - isSwitchOn ? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE : CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - ) - } - disabled={shouldBeDisabled} - /> - </View> - </OfflineWithFeedback> - </View> - {isSwitchOn && ( - <OfflineWithFeedback> - <MenuItemWithTopDescription - interactive={false} - title={isReportFieldsSelected ? translate('workspace.common.reportFields') : translate('workspace.common.tags')} - description={translate('workspace.common.displayedAs')} - wrapperStyle={styles.sectionMenuItemTopDescription} - /> - </OfflineWithFeedback> - )} - {shouldBeDisabled && ( - <View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter, styles.gap2, styles.mt1]}> - <Text style={styles.mutedTextLabel}>{translate('workspace.qbo.locationsAdditionalDescription')}</Text> - </View> - )} - </ScrollView> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + <ToggleSettingOptionRow + title={translate('workspace.accounting.import')} + switchAccessibilityLabel={translate('workspace.qbo.locations')} + isActive={isSwitchOn} + disabled={shouldBeDisabled} + onToggle={() => + QuickbooksOnline.updateQuickbooksOnlineSyncLocations( + policyID, + isSwitchOn ? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE : CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + qboConfig?.syncLocations, + ) + } + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.SYNC_LOCATIONS)} + onCloseError={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.SYNC_LOCATIONS)} + pendingAction={PolicyUtils.settingsPendingAction([CONST.QUICKBOOKS_CONFIG.SYNC_LOCATIONS], qboConfig?.pendingFields)} + /> + {isSwitchOn && ( + <MenuItemWithTopDescription + interactive={false} + title={isReportFieldsSelected ? translate('workspace.common.reportFields') : translate('workspace.common.tags')} + description={translate('workspace.common.displayedAs')} + wrapperStyle={styles.sectionMenuItemTopDescription} + /> + )} + {shouldBeDisabled && ( + <View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter, styles.gap2, styles.mt3]}> + <Text style={styles.mutedTextLabel}>{translate('workspace.qbo.locationsAdditionalDescription')}</Text> + </View> + )} + </ConnectionLayout> ); } diff --git a/src/pages/workspace/accounting/qbo/import/QuickbooksTaxesPage.tsx b/src/pages/workspace/accounting/qbo/import/QuickbooksTaxesPage.tsx index ecc1eaf7af5a..94156cfce54c 100644 --- a/src/pages/workspace/accounting/qbo/import/QuickbooksTaxesPage.tsx +++ b/src/pages/workspace/accounting/qbo/import/QuickbooksTaxesPage.tsx @@ -1,60 +1,48 @@ import React from 'react'; -import {View} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Switch from '@components/Switch'; +import ConnectionLayout from '@components/ConnectionLayout'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; -import variables from '@styles/variables'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; function QuickbooksTaxesPage({policy}: WithPolicyProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id ?? '-1'; - const {syncTax, pendingFields, reimbursableExpensesExportDestination} = policy?.connections?.quickbooksOnline?.config ?? {}; - const isJournalExportEntity = reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY; - + const qboConfig = policy?.connections?.quickbooksOnline?.config; + const isJournalExportEntity = qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY; return ( - <AccessOrNotFoundWrapper + <ConnectionLayout + displayName={QuickbooksTaxesPage.displayName} + headerTitle="workspace.accounting.taxes" + title="workspace.qbo.taxesDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} + contentContainerStyle={[[styles.pb2, styles.ph5]]} + connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} + onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT.getRoute(policyID))} > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - shouldEnableMaxHeight - testID={QuickbooksTaxesPage.displayName} - > - <HeaderWithBackButton title={translate('workspace.accounting.taxes')} /> - <ScrollView contentContainerStyle={[styles.pb2, styles.ph5]}> - <Text style={styles.pb5}>{translate('workspace.qbo.taxesDescription')}</Text> - <View style={[styles.flexRow, styles.mb4, styles.alignItemsCenter, styles.justifyContentBetween]}> - <View style={styles.flex1}> - <Text fontSize={variables.fontSizeNormal}>{translate('workspace.accounting.import')}</Text> - </View> - <OfflineWithFeedback pendingAction={pendingFields?.syncTax}> - <View style={[styles.flex1, styles.alignItemsEnd, styles.pl3]}> - <Switch - accessibilityLabel={translate('workspace.accounting.taxes')} - isOn={!!syncTax} - onToggle={() => QuickbooksOnline.updateQuickbooksOnlineSyncTax(policyID, !syncTax)} - disabled={!syncTax && isJournalExportEntity} - /> - </View> - </OfflineWithFeedback> - </View> - {!syncTax && isJournalExportEntity && <Text style={[styles.mutedNormalTextLabel, styles.pt2]}>{translate('workspace.qbo.taxesJournalEntrySwitchNote')}</Text>} - </ScrollView> - </ScreenWrapper> - </AccessOrNotFoundWrapper> + <ToggleSettingOptionRow + title={translate('workspace.accounting.import')} + switchAccessibilityLabel={translate('workspace.accounting.taxes')} + isActive={!!qboConfig?.syncTax} + onToggle={() => QuickbooksOnline.updateQuickbooksOnlineSyncTax(policyID, !qboConfig?.syncTax)} + pendingAction={settingsPendingAction([CONST.QUICKBOOKS_CONFIG.SYNC_TAX], qboConfig?.pendingFields)} + errors={ErrorUtils.getLatestErrorField(qboConfig, CONST.QUICKBOOKS_CONFIG.SYNC_TAX)} + onCloseError={() => clearQBOErrorField(policyID, CONST.QUICKBOOKS_CONFIG.SYNC_TAX)} + /> + {!qboConfig?.syncTax && isJournalExportEntity && <Text style={[styles.mutedNormalTextLabel, styles.pt2]}>{translate('workspace.qbo.taxesJournalEntrySwitchNote')}</Text>} + </ConnectionLayout> ); } diff --git a/src/pages/workspace/accounting/types.ts b/src/pages/workspace/accounting/types.ts index ed4f11312f2a..4ff7728e5575 100644 --- a/src/pages/workspace/accounting/types.ts +++ b/src/pages/workspace/accounting/types.ts @@ -14,8 +14,7 @@ type PolicyAccountingPageOnyxProps = { type PolicyAccountingPageProps = WithPolicyConnectionsProps & PolicyAccountingPageOnyxProps & { - // This is not using OnyxEntry<OnyxTypes.Policy> because the HOC withPolicyConnections will only render this component if there is a policy - policy: Policy; + policy: OnyxEntry<Policy>; }; type WorkspaceUpgradeNavigationDetails = { diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx index 9fce0f706790..b7b63502c842 100644 --- a/src/pages/workspace/accounting/utils.tsx +++ b/src/pages/workspace/accounting/utils.tsx @@ -17,7 +17,7 @@ import {getTrackingCategories} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; -import type {PolicyConnectionName} from '@src/types/onyx/Policy'; +import type {ConnectionName, PolicyConnectionName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import { getImportCustomFieldsSettings, @@ -43,9 +43,12 @@ function getAccountingIntegrationData( translate: LocaleContextProps['translate'], policy?: Policy, key?: number, + integrationToDisconnect?: ConnectionName, + shouldDisconnectIntegrationBeforeConnecting?: boolean, canUseNetSuiteUSATax?: boolean, ): AccountingIntegration | undefined { - const netsuiteConfig = policy?.connections?.netsuite?.options.config; + const qboConfig = policy?.connections?.quickbooksOnline?.config; + const netsuiteConfig = policy?.connections?.netsuite?.options?.config; const netsuiteSelectedSubsidiary = (policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === netsuiteConfig?.subsidiaryID); switch (connectionName) { @@ -60,9 +63,39 @@ function getAccountingIntegrationData( /> ), onImportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT.getRoute(policyID)), + subscribedImportSettings: [ + CONST.QUICKBOOKS_CONFIG.ENABLE_NEW_CATEGORIES, + CONST.QUICKBOOKS_CONFIG.SYNC_CLASSES, + CONST.QUICKBOOKS_CONFIG.SYNC_CUSTOMERS, + CONST.QUICKBOOKS_CONFIG.SYNC_LOCATIONS, + CONST.QUICKBOOKS_CONFIG.SYNC_TAX, + ], onExportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.getRoute(policyID)), + subscribedExportSettings: [ + CONST.QUICKBOOKS_CONFIG.EXPORTER, + CONST.QUICKBOOKS_CONFIG.EXPORT_DATE, + CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION, + CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT, + CONST.QUICKBOOKS_CONFIG.RECEIVABLE_ACCOUNT, + CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION, + CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSE_ACCOUNT, + ...(qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL + ? [CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR] + : []), + ...(qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL && + policy?.connections?.quickbooksOnline?.config?.autoCreateVendor + ? [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR] + : []), + ], onCardReconciliationPagePress: () => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, CONST.POLICY.CONNECTIONS.ROUTE.QBO)), onAdvancedPagePress: () => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID)), + subscribedAdvancedSettings: [ + CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID, + CONST.QUICKBOOKS_CONFIG.AUTO_SYNC, + CONST.QUICKBOOKS_CONFIG.SYNC_PEOPLE, + CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR, + ...(qboConfig?.collectionAccountID ? [CONST.QUICKBOOKS_CONFIG.REIMBURSEMENT_ACCOUNT_ID, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID] : []), + ], }; case CONST.POLICY.CONNECTIONS.NAME.XERO: return { @@ -158,7 +191,9 @@ function getAccountingIntegrationData( ], workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.netsuite.alias, - backToAfterWorkspaceUpgradeRoute: ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID), + backToAfterWorkspaceUpgradeRoute: integrationToDisconnect + ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) + : ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID), }, pendingFields: {...netsuiteConfig?.pendingFields, ...policy?.connections?.netsuite?.config?.pendingFields}, errorFields: {...netsuiteConfig?.errorFields, ...policy?.connections?.netsuite?.config?.errorFields}, @@ -203,7 +238,9 @@ function getAccountingIntegrationData( ], workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.intacct.alias, - backToAfterWorkspaceUpgradeRoute: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES.getRoute(policyID), + backToAfterWorkspaceUpgradeRoute: integrationToDisconnect + ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) + : ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES.getRoute(policyID), }, pendingFields: policy?.connections?.intacct?.config?.pendingFields, errorFields: policy?.connections?.intacct?.config?.errorFields, diff --git a/src/pages/workspace/accounting/xero/advanced/XeroAdvancedPage.tsx b/src/pages/workspace/accounting/xero/advanced/XeroAdvancedPage.tsx index 287163ae69cc..0650fce7a935 100644 --- a/src/pages/workspace/accounting/xero/advanced/XeroAdvancedPage.tsx +++ b/src/pages/workspace/accounting/xero/advanced/XeroAdvancedPage.tsx @@ -59,7 +59,7 @@ function XeroAdvancedPage({policy}: WithPolicyConnectionsProps) { wrapperStyle={styles.mv3} isActive={!!autoSync?.enabled} onToggle={() => - Connections.updatePolicyXeroConnectionConfig( + Connections.updatePolicyConnectionConfig( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.AUTO_SYNC, @@ -82,7 +82,7 @@ function XeroAdvancedPage({policy}: WithPolicyConnectionsProps) { wrapperStyle={styles.mv3} isActive={!!sync?.syncReimbursedReports} onToggle={() => - Connections.updatePolicyXeroConnectionConfig( + Connections.updatePolicyConnectionConfig( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.SYNC, diff --git a/src/pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage.tsx b/src/pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage.tsx index 0e940435816a..51ef936cd211 100644 --- a/src/pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage.tsx +++ b/src/pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage.tsx @@ -42,7 +42,7 @@ function XeroBillPaymentAccountSelectorPage({policy}: WithPolicyConnectionsProps const updateAccount = useCallback( ({value}: SelectorType) => { - Connections.updatePolicyXeroConnectionConfig( + Connections.updatePolicyConnectionConfig( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.SYNC, diff --git a/src/pages/workspace/accounting/xero/advanced/XeroInvoiceAccountSelectorPage.tsx b/src/pages/workspace/accounting/xero/advanced/XeroInvoiceAccountSelectorPage.tsx index de3b4be0b656..2bd102353e5b 100644 --- a/src/pages/workspace/accounting/xero/advanced/XeroInvoiceAccountSelectorPage.tsx +++ b/src/pages/workspace/accounting/xero/advanced/XeroInvoiceAccountSelectorPage.tsx @@ -42,7 +42,7 @@ function XeroInvoiceAccountSelectorPage({policy}: WithPolicyConnectionsProps) { const updateAccount = useCallback( ({value}: SelectorType) => { - Connections.updatePolicyXeroConnectionConfig( + Connections.updatePolicyConnectionConfig( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.SYNC, diff --git a/src/pages/workspace/accounting/xero/export/XeroBankAccountSelectPage.tsx b/src/pages/workspace/accounting/xero/export/XeroBankAccountSelectPage.tsx index 4dfd2e92bd0c..9e6efbfc4cda 100644 --- a/src/pages/workspace/accounting/xero/export/XeroBankAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/XeroBankAccountSelectPage.tsx @@ -46,7 +46,7 @@ function XeroBankAccountSelectPage({policy}: WithPolicyConnectionsProps) { const updateBankAccount = useCallback( ({value}: SelectorType) => { if (initiallyFocusedOptionKey !== value) { - Connections.updatePolicyXeroConnectionConfig( + Connections.updatePolicyConnectionConfig( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.EXPORT, diff --git a/src/pages/workspace/accounting/xero/export/XeroPreferredExporterSelectPage.tsx b/src/pages/workspace/accounting/xero/export/XeroPreferredExporterSelectPage.tsx index 7cbe523f859b..0dcfa6afbc23 100644 --- a/src/pages/workspace/accounting/xero/export/XeroPreferredExporterSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/XeroPreferredExporterSelectPage.tsx @@ -67,7 +67,7 @@ function XeroPreferredExporterSelectPage({policy}: WithPolicyConnectionsProps) { const selectExporter = useCallback( (row: CardListItem) => { if (row.value !== config?.export?.exporter) { - Connections.updatePolicyXeroConnectionConfig( + Connections.updatePolicyConnectionConfig( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.EXPORT, diff --git a/src/pages/workspace/accounting/xero/export/XeroPurchaseBillDateSelectPage.tsx b/src/pages/workspace/accounting/xero/export/XeroPurchaseBillDateSelectPage.tsx index 57b37f13d648..ee6f5171d1b3 100644 --- a/src/pages/workspace/accounting/xero/export/XeroPurchaseBillDateSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/XeroPurchaseBillDateSelectPage.tsx @@ -47,7 +47,7 @@ function XeroPurchaseBillDateSelectPage({policy}: WithPolicyConnectionsProps) { const selectExportDate = useCallback( (row: MenuListItem) => { if (row.value !== config?.export?.billDate) { - Connections.updatePolicyXeroConnectionConfig( + Connections.updatePolicyConnectionConfig( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.EXPORT, diff --git a/src/pages/workspace/accounting/xero/export/XeroPurchaseBillStatusSelectorPage.tsx b/src/pages/workspace/accounting/xero/export/XeroPurchaseBillStatusSelectorPage.tsx index d1902f8a3c46..2d124b05894f 100644 --- a/src/pages/workspace/accounting/xero/export/XeroPurchaseBillStatusSelectorPage.tsx +++ b/src/pages/workspace/accounting/xero/export/XeroPurchaseBillStatusSelectorPage.tsx @@ -52,7 +52,7 @@ function XeroPurchaseBillStatusSelectorPage({policy}: WithPolicyConnectionsProps return; } if (row.value !== invoiceStatus) { - Connections.updatePolicyXeroConnectionConfig( + Connections.updatePolicyConnectionConfig( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.EXPORT, diff --git a/src/pages/workspace/accounting/xero/import/XeroChartOfAccountsPage.tsx b/src/pages/workspace/accounting/xero/import/XeroChartOfAccountsPage.tsx index 20a6e24eb105..0712284e254d 100644 --- a/src/pages/workspace/accounting/xero/import/XeroChartOfAccountsPage.tsx +++ b/src/pages/workspace/accounting/xero/import/XeroChartOfAccountsPage.tsx @@ -60,7 +60,7 @@ function XeroChartOfAccountsPage({policy}: WithPolicyProps) { shouldPlaceSubtitleBelowSwitch isActive={!!xeroConfig?.enableNewCategories} onToggle={() => - Connections.updatePolicyXeroConnectionConfig( + Connections.updatePolicyConnectionConfig( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.ENABLE_NEW_CATEGORIES, diff --git a/src/pages/workspace/accounting/xero/import/XeroCustomerConfigurationPage.tsx b/src/pages/workspace/accounting/xero/import/XeroCustomerConfigurationPage.tsx index 29c7125213d1..9b17888ee0d6 100644 --- a/src/pages/workspace/accounting/xero/import/XeroCustomerConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/import/XeroCustomerConfigurationPage.tsx @@ -43,7 +43,7 @@ function XeroCustomerConfigurationPage({policy}: WithPolicyProps) { } isActive={isSwitchOn} onToggle={() => - Connections.updatePolicyXeroConnectionConfig( + Connections.updatePolicyConnectionConfig( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.XERO_CONFIG.IMPORT_CUSTOMERS, diff --git a/src/pages/workspace/categories/CategoryApproverPage.tsx b/src/pages/workspace/categories/CategoryApproverPage.tsx new file mode 100644 index 000000000000..390a577d9cf8 --- /dev/null +++ b/src/pages/workspace/categories/CategoryApproverPage.tsx @@ -0,0 +1,62 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import WorkspaceMembersSelectionList from '@components/WorkspaceMembersSelectionList'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CategoryUtils from '@libs/CategoryUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type EditCategoryPageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.CATEGORY_APPROVER>; + +function CategoryApproverPage({ + route: { + params: {policyID, categoryName}, + }, +}: EditCategoryPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policy = usePolicy(policyID); + + const selectedApprover = CategoryUtils.getCategoryApprover(policy?.rules?.approvalRules ?? [], categoryName) ?? ''; + + return ( + <AccessOrNotFoundWrapper + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} + policyID={policyID} + featureName={CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED} + > + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + style={[styles.defaultModalContainer]} + testID={CategoryApproverPage.displayName} + shouldEnableMaxHeight + > + <HeaderWithBackButton + title={translate('workspace.rules.categoryRules.approver')} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + <WorkspaceMembersSelectionList + policyID={policyID} + selectedApprover={selectedApprover} + setApprover={(email) => { + Category.setPolicyCategoryApprover(policyID, categoryName, email); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))); + }} + /> + </ScreenWrapper> + </AccessOrNotFoundWrapper> + ); +} + +CategoryApproverPage.displayName = 'CategoryApproverPage'; + +export default CategoryApproverPage; diff --git a/src/pages/workspace/categories/CategoryDefaultTaxRatePage.tsx b/src/pages/workspace/categories/CategoryDefaultTaxRatePage.tsx new file mode 100644 index 000000000000..16ea5b9bd2a7 --- /dev/null +++ b/src/pages/workspace/categories/CategoryDefaultTaxRatePage.tsx @@ -0,0 +1,97 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CategoryUtils from '@libs/CategoryUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {TaxRate} from '@src/types/onyx'; + +type EditCategoryPageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.CATEGORY_DEFAULT_TAX_RATE>; + +function CategoryDefaultTaxRatePage({ + route: { + params: {policyID, categoryName}, + }, +}: EditCategoryPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policy = usePolicy(policyID); + + const selectedTaxRate = CategoryUtils.getCategoryDefaultTaxRate(policy?.rules?.expenseRules ?? [], categoryName, policy?.taxRates?.defaultExternalID); + + const textForDefault = useCallback( + (taxID: string, taxRate: TaxRate) => CategoryUtils.formatDefaultTaxRateText(translate, taxID, taxRate, policy?.taxRates), + [policy?.taxRates, translate], + ); + + const taxesList = useMemo<ListItem[]>(() => { + if (!policy) { + return []; + } + return Object.entries(policy.taxRates?.taxes ?? {}) + .map(([key, value]) => ({ + text: textForDefault(key, value), + keyForList: key, + isSelected: key === selectedTaxRate, + isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + pendingAction: value.pendingAction ?? (Object.keys(value.pendingFields ?? {}).length > 0 ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null), + })) + .sort((a, b) => (a.text ?? a.keyForList ?? '').localeCompare(b.text ?? b.keyForList ?? '')); + }, [policy, selectedTaxRate, textForDefault]); + + return ( + <AccessOrNotFoundWrapper + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} + policyID={policyID} + featureName={CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED} + > + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + style={[styles.defaultModalContainer]} + testID={CategoryDefaultTaxRatePage.displayName} + shouldEnableMaxHeight + > + <HeaderWithBackButton + title={translate('workspace.rules.categoryRules.defaultTaxRate')} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + <SelectionList + sections={[{data: taxesList}]} + ListItem={RadioListItem} + onSelectRow={(item) => { + if (!item.keyForList) { + return; + } + + if (item.keyForList === selectedTaxRate) { + Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName)); + return; + } + + Category.setPolicyCategoryTax(policyID, categoryName, item.keyForList); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))); + }} + shouldSingleExecuteRowSelect + containerStyle={[styles.pt3]} + initiallyFocusedOptionKey={selectedTaxRate} + /> + </ScreenWrapper> + </AccessOrNotFoundWrapper> + ); +} + +CategoryDefaultTaxRatePage.displayName = 'CategoryDefaultTaxRatePage'; + +export default CategoryDefaultTaxRatePage; diff --git a/src/pages/workspace/categories/CategoryDescriptionHintPage.tsx b/src/pages/workspace/categories/CategoryDescriptionHintPage.tsx new file mode 100644 index 000000000000..7589c9b19881 --- /dev/null +++ b/src/pages/workspace/categories/CategoryDescriptionHintPage.tsx @@ -0,0 +1,85 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/WorkspaceCategoryDescriptionHintForm'; + +type EditCategoryPageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.CATEGORY_DESCRIPTION_HINT>; + +function CategoryDescriptionHintPage({ + route: { + params: {policyID, categoryName}, + }, +}: EditCategoryPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + + const {inputCallbackRef} = useAutoFocusInput(); + + const commentHintDefaultValue = policyCategories?.[categoryName]?.commentHint; + + return ( + <AccessOrNotFoundWrapper + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} + policyID={policyID} + featureName={CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED} + > + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + style={[styles.defaultModalContainer]} + testID={CategoryDescriptionHintPage.displayName} + shouldEnableMaxHeight + > + <HeaderWithBackButton + title={translate('workspace.rules.categoryRules.descriptionHint')} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + <FormProvider + style={[styles.flexGrow1, styles.mh5]} + formID={ONYXKEYS.FORMS.WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM} + onSubmit={({commentHint}) => { + Category.setWorkspaceCategoryDescriptionHint(policyID, categoryName, commentHint); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))); + }} + submitButtonText={translate('common.save')} + enabledWhenOffline + > + <View style={styles.mb4}> + <Text style={styles.pb5}>{translate('workspace.rules.categoryRules.descriptionHintDescription', categoryName)}</Text> + <InputWrapper + InputComponent={TextInput} + inputID={INPUT_IDS.COMMENT_HINT} + defaultValue={commentHintDefaultValue} + label={translate('workspace.rules.categoryRules.descriptionHintLabel')} + aria-label={translate('workspace.rules.categoryRules.descriptionHintLabel')} + ref={inputCallbackRef} + /> + <Text style={[styles.mutedTextLabel, styles.mt2]}>{translate('workspace.rules.categoryRules.descriptionHintSubtitle')}</Text> + </View> + </FormProvider> + </ScreenWrapper> + </AccessOrNotFoundWrapper> + ); +} + +CategoryDescriptionHintPage.displayName = 'CategoryDescriptionHintPage'; + +export default CategoryDescriptionHintPage; diff --git a/src/pages/workspace/categories/CategoryFlagAmountsOverPage.tsx b/src/pages/workspace/categories/CategoryFlagAmountsOverPage.tsx new file mode 100644 index 000000000000..1db409c9aaef --- /dev/null +++ b/src/pages/workspace/categories/CategoryFlagAmountsOverPage.tsx @@ -0,0 +1,105 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/WorkspaceCategoryFlagAmountsOverForm'; +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; +import ExpenseLimitTypeSelector from './ExpenseLimitTypeSelector/ExpenseLimitTypeSelector'; + +type EditCategoryPageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.CATEGORY_FLAG_AMOUNTS_OVER>; + +function CategoryFlagAmountsOverPage({ + route: { + params: {policyID, categoryName}, + }, +}: EditCategoryPageProps) { + const policy = usePolicy(policyID); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const [expenseLimitType, setExpenseLimitType] = useState<PolicyCategoryExpenseLimitType>(policyCategories?.[categoryName]?.expenseLimitType ?? CONST.POLICY.EXPENSE_LIMIT_TYPES.EXPENSE); + + const {inputCallbackRef} = useAutoFocusInput(); + + const policyCategoryMaxExpenseAmount = policyCategories?.[categoryName]?.maxExpenseAmount; + + const defaultValue = + policyCategoryMaxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE || !policyCategoryMaxExpenseAmount + ? '' + : CurrencyUtils.convertToFrontendAmountAsString(policyCategoryMaxExpenseAmount, policy?.outputCurrency); + + return ( + <AccessOrNotFoundWrapper + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} + policyID={policyID} + featureName={CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED} + > + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + style={[styles.defaultModalContainer]} + testID={CategoryFlagAmountsOverPage.displayName} + shouldEnableMaxHeight + > + <HeaderWithBackButton + title={translate('workspace.rules.categoryRules.flagAmountsOver')} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + <FormProvider + style={[styles.flexGrow1]} + formID={ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER_FORM} + onSubmit={({maxExpenseAmount}) => { + Category.setPolicyCategoryMaxAmount(policyID, categoryName, maxExpenseAmount, expenseLimitType); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))); + }} + submitButtonText={translate('workspace.editor.save')} + enabledWhenOffline + submitButtonStyles={styles.ph5} + > + <View style={[styles.mb4, styles.pt3, styles.ph5]}> + <Text style={styles.pb5}>{translate('workspace.rules.categoryRules.flagAmountsOverDescription', categoryName)}</Text> + <InputWrapper + label={translate('iou.amount')} + InputComponent={AmountForm} + inputID={INPUT_IDS.MAX_EXPENSE_AMOUNT} + currency={CurrencyUtils.getCurrencySymbol(policy?.outputCurrency ?? CONST.CURRENCY.USD)} + defaultValue={defaultValue} + isCurrencyPressable={false} + ref={inputCallbackRef} + displayAsTextInput + /> + <Text style={[styles.mutedTextLabel, styles.mt2]}>{translate('workspace.rules.categoryRules.flagAmountsOverSubtitle')}</Text> + </View> + <ExpenseLimitTypeSelector + label={translate('common.type')} + defaultValue={expenseLimitType} + wrapperStyle={[styles.ph5, styles.mt3]} + setNewExpenseLimitType={setExpenseLimitType} + /> + </FormProvider> + </ScreenWrapper> + </AccessOrNotFoundWrapper> + ); +} + +CategoryFlagAmountsOverPage.displayName = 'CategoryFlagAmountsOverPage'; + +export default CategoryFlagAmountsOverPage; diff --git a/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx b/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx new file mode 100644 index 000000000000..fa6b6555ec53 --- /dev/null +++ b/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx @@ -0,0 +1,114 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type EditCategoryPageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER>; + +function getInitiallyFocusedOptionKey(isAlwaysSelected: boolean, isNeverSelected: boolean): ValueOf<typeof CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS> { + if (isAlwaysSelected) { + return CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.ALWAYS; + } + + if (isNeverSelected) { + return CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.NEVER; + } + + return CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.DEFAULT; +} + +function CategoryRequireReceiptsOverPage({ + route: { + params: {policyID, categoryName}, + }, +}: EditCategoryPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policy = usePolicy(policyID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + + const isAlwaysSelected = policyCategories?.[categoryName]?.maxExpenseAmountNoReceipt === 0; + const isNeverSelected = policyCategories?.[categoryName]?.maxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; + const maxExpenseAmountToDisplay = policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmount; + + const requireReceiptsOverListData = [ + { + value: null, + text: translate( + `workspace.rules.categoryRules.requireReceiptsOverList.default`, + CurrencyUtils.convertToShortDisplayString(maxExpenseAmountToDisplay, policy?.outputCurrency ?? CONST.CURRENCY.USD), + ), + keyForList: CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.DEFAULT, + isSelected: !isAlwaysSelected && !isNeverSelected, + }, + { + value: CONST.DISABLED_MAX_EXPENSE_VALUE, + text: translate(`workspace.rules.categoryRules.requireReceiptsOverList.never`), + keyForList: CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.NEVER, + isSelected: isNeverSelected, + }, + { + value: 0, + text: translate(`workspace.rules.categoryRules.requireReceiptsOverList.always`), + keyForList: CONST.POLICY.REQUIRE_RECEIPTS_OVER_OPTIONS.ALWAYS, + isSelected: isAlwaysSelected, + }, + ]; + + const initiallyFocusedOptionKey = getInitiallyFocusedOptionKey(isAlwaysSelected, isNeverSelected); + + return ( + <AccessOrNotFoundWrapper + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} + policyID={policyID} + featureName={CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED} + > + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + style={[styles.defaultModalContainer]} + testID={CategoryRequireReceiptsOverPage.displayName} + shouldEnableMaxHeight + > + <HeaderWithBackButton + title={translate('workspace.rules.categoryRules.requireReceiptsOver')} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + <SelectionList + sections={[{data: requireReceiptsOverListData}]} + ListItem={RadioListItem} + onSelectRow={(item) => { + if (typeof item.value === 'number') { + Category.setPolicyCategoryReceiptsRequired(policyID, categoryName, item.value); + } else { + Category.removePolicyCategoryReceiptsRequired(policyID, categoryName); + } + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))); + }} + shouldSingleExecuteRowSelect + containerStyle={[styles.pt3]} + initiallyFocusedOptionKey={initiallyFocusedOptionKey} + /> + </ScreenWrapper> + </AccessOrNotFoundWrapper> + ); +} + +CategoryRequireReceiptsOverPage.displayName = 'CategoryRequireReceiptsOverPage'; + +export default CategoryRequireReceiptsOverPage; diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index af8b62a5a061..0cac6b8a7bda 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -1,8 +1,8 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; import ConfirmModal from '@components/ConfirmModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -10,18 +10,21 @@ import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; import Switch from '@components/Switch'; import Text from '@components/Text'; +import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as CategoryUtils from '@libs/CategoryUtils'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isControlPolicy} from '@libs/PolicyUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import {setWorkspaceCategoryEnabled} from '@userActions/Policy/Category'; import * as Category from '@userActions/Policy/Category'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -36,49 +39,95 @@ type CategorySettingsPageOnyxProps = { type CategorySettingsPageProps = CategorySettingsPageOnyxProps & StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.CATEGORY_SETTINGS>; -function CategorySettingsPage({route, policyCategories, navigation}: CategorySettingsPageProps) { +function CategorySettingsPage({ + route: { + params: {backTo, policyID, categoryName}, + }, + policyCategories, + navigation, +}: CategorySettingsPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [deleteCategoryConfirmModalVisible, setDeleteCategoryConfirmModalVisible] = useState(false); - const backTo = route.params?.backTo; - const policy = usePolicy(route.params.policyID); + const policy = usePolicy(policyID); + + const policyCategory = policyCategories?.[categoryName] ?? Object.values(policyCategories ?? {}).find((category) => category.previousCategoryName === categoryName); + const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + const policyCategoryExpenseLimitType = policyCategory?.expenseLimitType ?? CONST.POLICY.EXPENSE_LIMIT_TYPES.EXPENSE; - const policyCategory = - policyCategories?.[route.params.categoryName] ?? Object.values(policyCategories ?? {}).find((category) => category.previousCategoryName === route.params.categoryName); + const areCommentsRequired = policyCategory?.areCommentsRequired ?? false; const navigateBack = () => { if (backTo) { - Navigation.goBack(ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute(route.params.policyID, backTo)); + Navigation.goBack(ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute(policyID, backTo)); return; } Navigation.goBack(); }; useEffect(() => { - if (policyCategory?.name === route.params.categoryName || !policyCategory) { + if (policyCategory?.name === categoryName || !policyCategory) { return; } navigation.setParams({categoryName: policyCategory?.name}); - }, [route.params.categoryName, navigation, policyCategory]); + }, [categoryName, navigation, policyCategory]); + + const flagAmountsOverText = useMemo(() => { + if (policyCategory?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE || !policyCategory?.maxExpenseAmount) { + return ''; + } + + return `${CurrencyUtils.convertToDisplayString(policyCategory?.maxExpenseAmount, policyCurrency)} ${CONST.DOT_SEPARATOR} ${translate( + `workspace.rules.categoryRules.expenseLimitTypes.${policyCategoryExpenseLimitType}`, + )}`; + }, [policyCategory?.maxExpenseAmount, policyCategoryExpenseLimitType, policyCurrency, translate]); + + const approverText = useMemo(() => { + const categoryApprover = CategoryUtils.getCategoryApprover(policy?.rules?.approvalRules ?? [], categoryName); + return categoryApprover ?? ''; + }, [categoryName, policy?.rules?.approvalRules]); + + const defaultTaxRateText = useMemo(() => { + const taxID = CategoryUtils.getCategoryDefaultTaxRate(policy?.rules?.expenseRules ?? [], categoryName, policy?.taxRates?.defaultExternalID); + + if (!taxID) { + return ''; + } + + const taxRate = policy?.taxRates?.taxes[taxID]; + + if (!taxRate) { + return ''; + } + + return CategoryUtils.formatDefaultTaxRateText(translate, taxID, taxRate, policy?.taxRates); + }, [categoryName, policy?.rules?.expenseRules, policy?.taxRates, translate]); + + const requireReceiptsOverText = useMemo(() => { + if (!policy) { + return ''; + } + return CategoryUtils.formatRequireReceiptsOverText(translate, policy, policyCategory?.maxExpenseAmountNoReceipt); + }, [policy, policyCategory?.maxExpenseAmountNoReceipt, translate]); if (!policyCategory) { return <NotFoundPage />; } const updateWorkspaceRequiresCategory = (value: boolean) => { - setWorkspaceCategoryEnabled(route.params.policyID, {[policyCategory.name]: {name: policyCategory.name, enabled: value}}); + Category.setWorkspaceCategoryEnabled(policyID, {[policyCategory.name]: {name: policyCategory.name, enabled: value}}); }; const navigateToEditCategory = () => { if (backTo) { - Navigation.navigate(ROUTES.SETTINGS_CATEGORY_EDIT.getRoute(route.params.policyID, policyCategory.name, backTo)); + Navigation.navigate(ROUTES.SETTINGS_CATEGORY_EDIT.getRoute(policyID, policyCategory.name, backTo)); return; } - Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_EDIT.getRoute(route.params.policyID, policyCategory.name)); + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_EDIT.getRoute(policyID, policyCategory.name)); }; const deleteCategory = () => { - Category.deleteWorkspaceCategories(route.params.policyID, [route.params.categoryName]); + Category.deleteWorkspaceCategories(policyID, [categoryName]); setDeleteCategoryConfirmModalVisible(false); navigateBack(); }; @@ -88,7 +137,7 @@ function CategorySettingsPage({route, policyCategories, navigation}: CategorySet return ( <AccessOrNotFoundWrapper accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} - policyID={route.params.policyID} + policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED} > <ScreenWrapper @@ -96,94 +145,187 @@ function CategorySettingsPage({route, policyCategories, navigation}: CategorySet style={[styles.defaultModalContainer]} testID={CategorySettingsPage.displayName} > - <HeaderWithBackButton - title={route.params.categoryName} - onBackButtonPress={navigateBack} - /> - <ConfirmModal - isVisible={deleteCategoryConfirmModalVisible} - onConfirm={deleteCategory} - onCancel={() => setDeleteCategoryConfirmModalVisible(false)} - title={translate('workspace.categories.deleteCategory')} - prompt={translate('workspace.categories.deleteCategoryPrompt')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - <View style={styles.flexGrow1}> - <OfflineWithFeedback - errors={ErrorUtils.getLatestErrorMessageField(policyCategory)} - pendingAction={policyCategory?.pendingFields?.enabled} - errorRowStyles={styles.mh5} - onClose={() => Category.clearCategoryErrors(route.params.policyID, route.params.categoryName)} - > - <View style={[styles.mt2, styles.mh5]}> - <View style={[styles.flexRow, styles.mb5, styles.mr2, styles.alignItemsCenter, styles.justifyContentBetween]}> - <Text style={[styles.flexShrink1, styles.mr2]}>{translate('workspace.categories.enableCategory')}</Text> - <Switch - isOn={policyCategory.enabled} - accessibilityLabel={translate('workspace.categories.enableCategory')} - onToggle={updateWorkspaceRequiresCategory} - /> - </View> - </View> - </OfflineWithFeedback> - <OfflineWithFeedback pendingAction={policyCategory.pendingFields?.name}> - <MenuItemWithTopDescription - title={policyCategory.name} - description={translate(`common.name`)} - onPress={navigateToEditCategory} - shouldShowRightIcon + {({safeAreaPaddingBottomStyle}) => ( + <> + <HeaderWithBackButton + title={categoryName} + onBackButtonPress={navigateBack} /> - </OfflineWithFeedback> - <OfflineWithFeedback pendingAction={policyCategory.pendingFields?.['GL Code']}> - <MenuItemWithTopDescription - title={policyCategory['GL Code']} - description={translate(`workspace.categories.glCode`)} - onPress={() => { - if (!isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute( - route.params.policyID, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, - ROUTES.WORKSPACE_CATEGORY_GL_CODE.getRoute(route.params.policyID, policyCategory.name), - ), - ); - return; - } - Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_GL_CODE.getRoute(route.params.policyID, policyCategory.name)); - }} - shouldShowRightIcon + <ConfirmModal + isVisible={deleteCategoryConfirmModalVisible} + onConfirm={deleteCategory} + onCancel={() => setDeleteCategoryConfirmModalVisible(false)} + title={translate('workspace.categories.deleteCategory')} + prompt={translate('workspace.categories.deleteCategoryPrompt')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger /> - </OfflineWithFeedback> - <OfflineWithFeedback pendingAction={policyCategory.pendingFields?.['Payroll Code']}> - <MenuItemWithTopDescription - title={policyCategory['Payroll Code']} - description={translate(`workspace.categories.payrollCode`)} - onPress={() => { - if (!isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute( - route.params.policyID, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, - ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.getRoute(route.params.policyID, policyCategory.name), - ), - ); - return; - } - Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.getRoute(route.params.policyID, policyCategory.name)); - }} - shouldShowRightIcon - /> - </OfflineWithFeedback> - {!isThereAnyAccountingConnection && ( - <MenuItem - icon={Expensicons.Trashcan} - title={translate('common.delete')} - onPress={() => setDeleteCategoryConfirmModalVisible(true)} - /> - )} - </View> + <ScrollView contentContainerStyle={[styles.flexGrow1, safeAreaPaddingBottomStyle]}> + <OfflineWithFeedback + errors={ErrorUtils.getLatestErrorMessageField(policyCategory)} + pendingAction={policyCategory?.pendingFields?.enabled} + errorRowStyles={styles.mh5} + onClose={() => Category.clearCategoryErrors(policyID, categoryName)} + > + <View style={[styles.mt2, styles.mh5]}> + <View style={[styles.flexRow, styles.mb5, styles.mr2, styles.alignItemsCenter, styles.justifyContentBetween]}> + <Text style={[styles.flexShrink1, styles.mr2]}>{translate('workspace.categories.enableCategory')}</Text> + <Switch + isOn={policyCategory.enabled} + accessibilityLabel={translate('workspace.categories.enableCategory')} + onToggle={updateWorkspaceRequiresCategory} + /> + </View> + </View> + </OfflineWithFeedback> + <OfflineWithFeedback pendingAction={policyCategory.pendingFields?.name}> + <MenuItemWithTopDescription + title={policyCategory.name} + description={translate('common.name')} + onPress={navigateToEditCategory} + shouldShowRightIcon + /> + </OfflineWithFeedback> + <OfflineWithFeedback pendingAction={policyCategory.pendingFields?.['GL Code']}> + <MenuItemWithTopDescription + title={policyCategory['GL Code']} + description={translate('workspace.categories.glCode')} + onPress={() => { + if (!isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute( + policyID, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, + ROUTES.WORKSPACE_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name), + ), + ); + return; + } + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_GL_CODE.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + </OfflineWithFeedback> + <OfflineWithFeedback pendingAction={policyCategory.pendingFields?.['Payroll Code']}> + <MenuItemWithTopDescription + title={policyCategory['Payroll Code']} + description={translate('workspace.categories.payrollCode')} + onPress={() => { + if (!isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute( + policyID, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.glAndPayrollCodes.alias, + ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.getRoute(policyID, policyCategory.name), + ), + ); + return; + } + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + </OfflineWithFeedback> + + {policy?.areRulesEnabled && ( + <> + <View style={[styles.mh5, styles.pt3, styles.borderTop]}> + <Text style={[styles.textNormal, styles.textStrong, styles.mv3]}>{translate('workspace.rules.categoryRules.title')}</Text> + </View> + <OfflineWithFeedback pendingAction={policyCategory?.pendingFields?.areCommentsRequired}> + <View style={[styles.mt2, styles.mh5]}> + <View style={[styles.flexRow, styles.mb5, styles.mr2, styles.alignItemsCenter, styles.justifyContentBetween]}> + <Text style={[styles.flexShrink1, styles.mr2]}>{translate('workspace.rules.categoryRules.requireDescription')}</Text> + <Switch + isOn={policyCategory?.areCommentsRequired ?? false} + accessibilityLabel={translate('workspace.rules.categoryRules.requireDescription')} + onToggle={() => Category.setPolicyCategoryDescriptionRequired(policyID, categoryName, !areCommentsRequired)} + /> + </View> + </View> + </OfflineWithFeedback> + {policyCategory?.areCommentsRequired && ( + <OfflineWithFeedback pendingAction={policyCategory.pendingFields?.commentHint}> + <MenuItemWithTopDescription + title={policyCategory?.commentHint} + description={translate('workspace.rules.categoryRules.descriptionHint')} + onPress={() => { + Navigation.navigate(ROUTES.WORSKPACE_CATEGORY_DESCRIPTION_HINT.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + </OfflineWithFeedback> + )} + <OfflineWithFeedback pendingAction={policy?.rules?.pendingFields?.approvalRules}> + <MenuItemWithTopDescription + title={approverText} + description={translate('workspace.rules.categoryRules.approver')} + onPress={() => { + Navigation.navigate(ROUTES.WORSKPACE_CATEGORY_APPROVER.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + disabled={!policy?.areWorkflowsEnabled} + /> + </OfflineWithFeedback> + {!policy?.areWorkflowsEnabled && ( + <Text style={[styles.flexRow, styles.alignItemsCenter, styles.mv2, styles.mh5]}> + <Text style={[styles.textLabel, styles.colorMuted]}>{translate('workspace.rules.categoryRules.goTo')}</Text>{' '} + <TextLink + style={[styles.link, styles.label]} + onPress={() => Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID))} + > + {translate('workspace.common.moreFeatures')} + </TextLink>{' '} + <Text style={[styles.textLabel, styles.colorMuted]}>{translate('workspace.rules.categoryRules.andEnableWorkflows')}</Text> + </Text> + )} + {policy?.tax?.trackingEnabled && ( + <OfflineWithFeedback pendingAction={policy?.rules?.pendingFields?.expenseRules}> + <MenuItemWithTopDescription + title={defaultTaxRateText} + description={translate('workspace.rules.categoryRules.defaultTaxRate')} + onPress={() => { + Navigation.navigate(ROUTES.WORSKPACE_CATEGORY_DEFAULT_TAX_RATE.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + </OfflineWithFeedback> + )} + + <OfflineWithFeedback pendingAction={policyCategory.pendingFields?.maxExpenseAmount}> + <MenuItemWithTopDescription + title={flagAmountsOverText} + description={translate('workspace.rules.categoryRules.flagAmountsOver')} + onPress={() => { + Navigation.navigate(ROUTES.WORSKPACE_CATEGORY_FLAG_AMOUNTS_OVER.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + </OfflineWithFeedback> + <OfflineWithFeedback pendingAction={policyCategory.pendingFields?.maxExpenseAmountNoReceipt}> + <MenuItemWithTopDescription + title={requireReceiptsOverText} + description={translate(`workspace.rules.categoryRules.requireReceiptsOver`)} + onPress={() => { + Navigation.navigate(ROUTES.WORSKPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + </OfflineWithFeedback> + </> + )} + + {!isThereAnyAccountingConnection && ( + <MenuItem + icon={Expensicons.Trashcan} + title={translate('common.delete')} + onPress={() => setDeleteCategoryConfirmModalVisible(true)} + /> + )} + </ScrollView> + </> + )} </ScreenWrapper> </AccessOrNotFoundWrapper> ); diff --git a/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelector.tsx b/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelector.tsx new file mode 100644 index 000000000000..e6c30a4913e1 --- /dev/null +++ b/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelector.tsx @@ -0,0 +1,69 @@ +import React, {useState} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; +import ExpenseLimitTypeSelectorModal from './ExpenseLimitTypeSelectorModal'; + +type ExpenseLimitTypeSelectorProps = { + /** Function to call when the user selects an expense limit type */ + setNewExpenseLimitType: (value: PolicyCategoryExpenseLimitType) => void; + + /** Currently selected expense limit type */ + defaultValue: PolicyCategoryExpenseLimitType; + + /** Label to display on field */ + label: string; + + /** Any additional styles to apply */ + wrapperStyle: StyleProp<ViewStyle>; +}; + +function ExpenseLimitTypeSelector({defaultValue, wrapperStyle, label, setNewExpenseLimitType}: ExpenseLimitTypeSelectorProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const [isPickerVisible, setIsPickerVisible] = useState(false); + + const showPickerModal = () => { + setIsPickerVisible(true); + }; + + const hidePickerModal = () => { + setIsPickerVisible(false); + }; + + const updateExpenseLimitTypeInput = (expenseLimitType: PolicyCategoryExpenseLimitType) => { + setNewExpenseLimitType(expenseLimitType); + hidePickerModal(); + }; + + const title = translate(`workspace.rules.categoryRules.expenseLimitTypes.${defaultValue}`); + const descStyle = !title ? styles.textNormal : null; + + return ( + <View> + <MenuItemWithTopDescription + shouldShowRightIcon + title={title} + description={label} + descriptionTextStyle={descStyle} + onPress={showPickerModal} + wrapperStyle={wrapperStyle} + /> + <ExpenseLimitTypeSelectorModal + isVisible={isPickerVisible} + currentExpenseLimitType={defaultValue} + onClose={hidePickerModal} + onExpenseLimitTypeSelected={updateExpenseLimitTypeInput} + label={label} + /> + </View> + ); +} + +ExpenseLimitTypeSelector.displayName = 'ExpenseLimitTypeSelector'; + +export default ExpenseLimitTypeSelector; diff --git a/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelectorModal.tsx b/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelectorModal.tsx new file mode 100644 index 000000000000..34bd47c484ba --- /dev/null +++ b/src/pages/workspace/categories/ExpenseLimitTypeSelector/ExpenseLimitTypeSelectorModal.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Modal from '@components/Modal'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; + +type ExpenseLimitTypeSelectorModalProps = { + /** Whether the modal is visible */ + isVisible: boolean; + + /** Selected expense limit type */ + currentExpenseLimitType: PolicyCategoryExpenseLimitType; + + /** Function to call when the user selects an expense limit type */ + onExpenseLimitTypeSelected: (value: PolicyCategoryExpenseLimitType) => void; + + /** Function to call when the user closes the expense limit type selector modal */ + onClose: () => void; + + /** Label to display on field */ + label: string; +}; + +function ExpenseLimitTypeSelectorModal({isVisible, currentExpenseLimitType, onExpenseLimitTypeSelected, onClose, label}: ExpenseLimitTypeSelectorModalProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const expenseLimitTypes = Object.values(CONST.POLICY.EXPENSE_LIMIT_TYPES).map((value) => ({ + value, + text: translate(`workspace.rules.categoryRules.expenseLimitTypes.${value}`), + alternateText: translate(`workspace.rules.categoryRules.expenseLimitTypes.${value}Subtitle`), + keyForList: value, + isSelected: currentExpenseLimitType === value, + })); + + return ( + <Modal + type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED} + isVisible={isVisible} + onClose={onClose} + onModalHide={onClose} + hideModalContentWhileAnimating + useNativeDriver + > + <ScreenWrapper + style={[styles.pb0]} + includePaddingTop={false} + includeSafeAreaPaddingBottom={false} + testID={ExpenseLimitTypeSelectorModal.displayName} + > + <HeaderWithBackButton + title={label} + shouldShowBackButton + onBackButtonPress={onClose} + /> + <SelectionList + sections={[{data: expenseLimitTypes}]} + ListItem={RadioListItem} + onSelectRow={(item) => onExpenseLimitTypeSelected(item.value)} + shouldSingleExecuteRowSelect + containerStyle={[styles.pt3]} + initiallyFocusedOptionKey={currentExpenseLimitType} + /> + </ScreenWrapper> + </Modal> + ); +} + +ExpenseLimitTypeSelectorModal.displayName = 'ExpenseLimitTypeSelectorModal'; + +export default ExpenseLimitTypeSelectorModal; diff --git a/src/pages/workspace/categories/ImportedCategoriesPage.tsx b/src/pages/workspace/categories/ImportedCategoriesPage.tsx index 585e5e2833ed..8e942b3060ff 100644 --- a/src/pages/workspace/categories/ImportedCategoriesPage.tsx +++ b/src/pages/workspace/categories/ImportedCategoriesPage.tsx @@ -25,9 +25,10 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) { const {translate} = useLocalize(); const [spreadsheet] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET); const [isImportingCategories, setIsImportingCategories] = useState(false); - const {containsHeader} = spreadsheet ?? {}; + const {containsHeader = true} = spreadsheet ?? {}; const [isValidationEnabled, setIsValidationEnabled] = useState(false); const policyID = route.params.policyID; + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const policy = usePolicy(policyID); const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); @@ -88,24 +89,34 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) { const categoriesNames = spreadsheet?.data[categoriesNamesColumn].map((name) => name); const categoriesEnabled = categoriesEnabledColumn !== -1 ? spreadsheet?.data[categoriesEnabledColumn].map((enabled) => enabled) : []; const categoriesGLCode = categoriesGLCodeColumn !== -1 ? spreadsheet?.data[categoriesGLCodeColumn].map((glCode) => glCode) : []; - const categories = categoriesNames?.slice(containsHeader ? 1 : 0).map((name, index) => ({ - name, - enabled: categoriesEnabledColumn !== -1 ? categoriesEnabled?.[containsHeader ? index + 1 : index] === 'true' : true, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'GL Code': categoriesGLCodeColumn !== -1 ? categoriesGLCode?.[containsHeader ? index + 1 : index] : '', - })); + const categories = categoriesNames?.slice(containsHeader ? 1 : 0).map((name, index) => { + const categoryAlreadyExists = policyCategories?.[name]; + const existingGLCodeOrDefault = categoryAlreadyExists?.['GL Code'] ?? ''; + return { + name, + enabled: categoriesEnabledColumn !== -1 ? categoriesEnabled?.[containsHeader ? index + 1 : index] === 'true' : true, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': categoriesGLCodeColumn !== -1 ? categoriesGLCode?.[containsHeader ? index + 1 : index] ?? '' : existingGLCodeOrDefault, + }; + }); if (categories) { setIsImportingCategories(true); importPolicyCategories(policyID, categories); } - }, [validate, spreadsheet, containsHeader, policyID]); + }, [validate, spreadsheet, containsHeader, policyID, policyCategories]); const spreadsheetColumns = spreadsheet?.data; if (!spreadsheetColumns) { return; } + const closeImportPageAndModal = () => { + setIsImportingCategories(false); + closeImportPage(); + Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); + }; + return ( <ScreenWrapper testID={ImportedCategoriesPage.displayName} @@ -130,11 +141,8 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) { isVisible={spreadsheet?.shouldFinalModalBeOpened} title={spreadsheet?.importFinalModal?.title ?? ''} prompt={spreadsheet?.importFinalModal?.prompt ?? ''} - onConfirm={() => { - setIsImportingCategories(false); - closeImportPage(); - Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); - }} + onConfirm={closeImportPageAndModal} + onCancel={closeImportPageAndModal} confirmText={translate('common.buttonConfirm')} shouldShowCancelButton={false} /> diff --git a/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx b/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx index 76b0f11b467a..fd958ebc181c 100644 --- a/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx +++ b/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx @@ -1,12 +1,14 @@ import React, {useState} from 'react'; +import type {SetOptional} from 'type-fest'; import BaseListItem from '@components/SelectionList/BaseListItem'; import type {BaseListItemProps, ListItem} from '@components/SelectionList/types'; import useThemeStyles from '@hooks/useThemeStyles'; -import blurActiveElement from '@libs/Accessibility/blurActiveElement'; import CategorySelector from '@pages/workspace/distanceRates/CategorySelector'; import * as Policy from '@userActions/Policy/Policy'; -function SpendCategorySelectorListItem<TItem extends ListItem>({item, onSelectRow, isFocused}: BaseListItemProps<TItem>) { +type SpendCategorySelectorListItemProps<TItem extends ListItem> = SetOptional<BaseListItemProps<TItem>, 'onSelectRow'>; + +function SpendCategorySelectorListItem<TItem extends ListItem>({item, onSelectRow = () => {}, isFocused}: SpendCategorySelectorListItemProps<TItem>) { const styles = useThemeStyles(); const [isCategoryPickerVisible, setIsCategoryPickerVisible] = useState(false); const {policyID, groupID, categoryID} = item; @@ -21,10 +23,10 @@ function SpendCategorySelectorListItem<TItem extends ListItem>({item, onSelectRo }; const setNewCategory = (selectedCategory: ListItem) => { - if (!selectedCategory.text) { + if (!selectedCategory.keyForList) { return; } - Policy.setWorkspaceDefaultSpendCategory(policyID, groupID, selectedCategory.text); + Policy.setWorkspaceDefaultSpendCategory(policyID, groupID, selectedCategory.keyForList); }; return ( @@ -48,9 +50,7 @@ function SpendCategorySelectorListItem<TItem extends ListItem>({item, onSelectRo showPickerModal={() => setIsCategoryPickerVisible(true)} hidePickerModal={() => { setIsCategoryPickerVisible(false); - blurActiveElement(); }} - shouldUseCustomScrollView /> </BaseListItem> ); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index a720b3f0e568..2de1d33e2765 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -36,6 +36,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {FullScreenNavigatorParamList} from '@libs/Navigation/types'; import * as PolicyUtils from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Modal from '@userActions/Modal'; import {deleteWorkspaceCategories, setWorkspaceCategoryEnabled} from '@userActions/Policy/Category'; import * as Category from '@userActions/Policy/Category'; import CONST from '@src/CONST'; @@ -300,15 +301,26 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const menuItems = [ { icon: Expensicons.Table, - text: translate('common.importSpreadsheet'), + text: translate('spreadsheet.importSpreadsheet'), onSelected: () => { if (isOffline) { - setIsOfflineModalVisible(true); + Modal.close(() => setIsOfflineModalVisible(true)); return; } Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES_IMPORT.getRoute(policyId)); }, }, + { + icon: Expensicons.Download, + text: translate('spreadsheet.downloadCSV'), + onSelected: () => { + if (isOffline) { + Modal.close(() => setIsOfflineModalVisible(true)); + return; + } + Category.downloadCategoriesCSV(policyId); + }, + }, ]; return menuItems; diff --git a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx index 03c01e5a7264..02547090cfe6 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx @@ -3,10 +3,11 @@ import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; +import ScrollView from '@components/ScrollView'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -25,6 +26,7 @@ type WorkspaceCategoriesSettingsPageProps = WithPolicyConnectionsProps; function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSettingsPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {canUseWorkspaceRules} = usePermissions(); const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0; const policyID = route.params.policyID ?? '-1'; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -37,28 +39,31 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet setWorkspaceRequiresCategory(policyID, value); }; - const {sections} = useMemo(() => { - if (!(currentPolicy && currentPolicy.mccGroup)) { - return {sections: [{data: []}]}; + const policyMccGroup = currentPolicy?.mccGroup; + const listItems = useMemo(() => { + let data: ListItem[] = []; + + if (policyMccGroup) { + data = Object.entries(policyMccGroup).map( + ([mccKey, mccGroup]) => + ({ + categoryID: mccGroup.category, + keyForList: mccKey, + groupID: mccKey, + policyID, + tabIndex: -1, + } as ListItem), + ); } - return { - sections: [ - { - data: Object.entries(currentPolicy.mccGroup).map( - ([mccKey, mccGroup]) => - ({ - categoryID: mccGroup.category, - keyForList: mccKey, - groupID: mccKey, - policyID, - tabIndex: -1, - } as ListItem), - ), - }, - ], - }; - }, [currentPolicy, policyID]); + return data.map((item) => ( + <SpendCategorySelectorListItem + key={item.keyForList} + item={item} + showTooltip + /> + )); + }, [policyMccGroup, policyID]); const hasEnabledOptions = OptionsListUtils.hasEnabledOptions(policyCategories ?? {}); const isToggleDisabled = !policy?.areCategoriesEnabled || !hasEnabledOptions || isConnectedToAccounting; @@ -89,18 +94,14 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet shouldPlaceSubtitleBelowSwitch /> <View style={[styles.containerWithSpaceBetween]}> - {!!currentPolicy && sections[0].data.length > 0 && ( - <SelectionList - headerContent={ - <View style={[styles.mh5, styles.mt2, styles.mb1]}> - <Text style={[styles.headerText]}>{translate('workspace.categories.defaultSpendCategories')}</Text> - <Text style={[styles.mt1, styles.lh20]}>{translate('workspace.categories.spendCategoriesDescription')}</Text> - </View> - } - sections={sections} - ListItem={SpendCategorySelectorListItem} - onSelectRow={() => {}} - /> + {!!currentPolicy && listItems.length > 0 && canUseWorkspaceRules && ( + <> + <View style={[styles.mh5, styles.mt2, styles.mb1]}> + <Text style={[styles.headerText]}>{translate('workspace.categories.defaultSpendCategories')}</Text> + <Text style={[styles.mt1, styles.lh20]}>{translate('workspace.categories.spendCategoriesDescription')}</Text> + </View> + <ScrollView>{listItems}</ScrollView> + </> )} </View> </ScreenWrapper> diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardPageEmptyState.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardPageEmptyState.tsx index 0846c8b9e179..65f054c4e7ce 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardPageEmptyState.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardPageEmptyState.tsx @@ -6,8 +6,12 @@ import * as Illustrations from '@components/Icon/Illustrations'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import colors from '@styles/theme/colors'; +import * as CompanyCards from '@userActions/CompanyCards'; +import ROUTES from '@src/ROUTES'; const companyCardFeatures: FeatureListItem[] = [ { @@ -24,14 +28,15 @@ const companyCardFeatures: FeatureListItem[] = [ }, ]; -function WorkspaceCompanyCardPageEmptyState() { +function WorkspaceCompanyCardPageEmptyState({policy}: WithPolicyAndFullscreenLoadingProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const startFlow = useCallback(() => { - // TODO: Add Card Feed Flow https://github.com/Expensify/App/issues/47376 - }, []); + CompanyCards.clearAddNewCardFlow(); + Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ADD_NEW.getRoute(policy?.id ?? '-1')); + }, [policy]); return ( <View style={[styles.mt3, shouldUseNarrowLayout ? styles.workspaceSectionMobile : styles.workspaceSection]}> diff --git a/src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx b/src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx new file mode 100644 index 000000000000..8d42b3e8408e --- /dev/null +++ b/src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import CardInstructionsStep from './CardInstructionsStep'; +import CardNameStep from './CardNameStep'; +import CardTypeStep from './CardTypeStep'; +import DetailsStep from './DetailsStep'; + +function AddNewCardPage() { + const [addNewCardFeed] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); + + const {currentStep} = addNewCardFeed ?? {}; + + switch (currentStep) { + case CONST.COMPANY_CARDS.STEP.CARD_TYPE: + return <CardTypeStep />; + case CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS: + return <CardInstructionsStep />; + case CONST.COMPANY_CARDS.STEP.CARD_NAME: + return <CardNameStep />; + case CONST.COMPANY_CARDS.STEP.CARD_DETAILS: + return <DetailsStep />; + default: + return <CardTypeStep />; + } +} + +AddNewCardPage.displayName = 'AddNewCardPage'; +export default withPolicyAndFullscreenLoading(AddNewCardPage); diff --git a/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx new file mode 100644 index 000000000000..ac1cff597a98 --- /dev/null +++ b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx @@ -0,0 +1,78 @@ +import {Str} from 'expensify-common'; +import React from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import RenderHTML from '@components/RenderHTML'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Parser from '@libs/Parser'; +import * as CompanyCards from '@userActions/CompanyCards'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function CardInstructionsStep() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + + const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); + + const data = addNewCard?.data; + const feedProvider = data?.cardType; + + const submit = () => { + CompanyCards.setAddNewCompanyCardStepAndData({ + step: feedProvider === CONST.COMPANY_CARDS.CARD_TYPE.AMEX ? CONST.COMPANY_CARDS.STEP.CARD_DETAILS : CONST.COMPANY_CARDS.STEP.CARD_NAME, + }); + }; + + const handleBackButtonPress = () => { + CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.CARD_TYPE}); + }; + + return ( + <ScreenWrapper + testID={CardInstructionsStep.displayName} + includeSafeAreaPaddingBottom={false} + shouldEnablePickerAvoiding={false} + shouldEnableMaxHeight + > + <HeaderWithBackButton + title={translate('workspace.companyCards.addCardFeed')} + onBackButtonPress={handleBackButtonPress} + /> + <ScrollView + style={styles.pt0} + contentContainerStyle={styles.flexGrow1} + > + <Text style={[styles.textHeadlineLineHeightXXL, styles.ph5, styles.mv3]}> + {translate('workspace.companyCards.addNewCard.enableFeed.title', Str.recapitalize(feedProvider ?? ''))} + </Text> + <Text style={[styles.ph5, styles.mb3]}>{translate('workspace.companyCards.addNewCard.enableFeed.heading')}</Text> + <View style={[styles.ph5]}> + <RenderHTML html={Parser.replace(feedProvider ? translate(`workspace.companyCards.addNewCard.enableFeed.${feedProvider}`) : '')} /> + </View> + <View style={[styles.mh5, styles.pb5, styles.mt3, styles.flexGrow1, styles.justifyContentEnd]}> + <Button + isDisabled={isOffline} + success + large + style={[styles.w100]} + onPress={submit} + text={translate('common.next')} + /> + </View> + </ScrollView> + </ScreenWrapper> + ); +} + +CardInstructionsStep.displayName = 'CardInstructionsStep'; + +export default CardInstructionsStep; diff --git a/src/pages/workspace/companyCards/addNew/CardNameStep.tsx b/src/pages/workspace/companyCards/addNew/CardNameStep.tsx new file mode 100644 index 000000000000..d8d6fe10acf4 --- /dev/null +++ b/src/pages/workspace/companyCards/addNew/CardNameStep.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import * as CompanyCards from '@userActions/CompanyCards'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/AddNewCardFeedForm'; + +function CardNameStep() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {inputCallbackRef} = useAutoFocusInput(); + const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); + + const validate = (values: FormOnyxValues<typeof ONYXKEYS.FORMS.ADD_NEW_CARD_FEED_FORM>): FormInputErrors<typeof ONYXKEYS.FORMS.ADD_NEW_CARD_FEED_FORM> => { + return ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.CARD_TITLE]); + }; + + const submit = (values: FormOnyxValues<typeof ONYXKEYS.FORMS.ADD_NEW_CARD_FEED_FORM>) => { + CompanyCards.setAddNewCompanyCardStepAndData({ + step: CONST.COMPANY_CARDS.STEP.CARD_DETAILS, + data: { + cardTitle: values.cardTitle, + }, + isEditing: false, + }); + }; + + const handleBackButtonPress = () => { + CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS}); + }; + + return ( + <ScreenWrapper + testID={CardNameStep.displayName} + includeSafeAreaPaddingBottom={false} + shouldEnablePickerAvoiding={false} + shouldEnableMaxHeight + > + <HeaderWithBackButton + title={translate('workspace.companyCards.addCardFeed')} + onBackButtonPress={handleBackButtonPress} + /> + <Text style={[styles.textHeadlineLineHeightXXL, styles.ph5, styles.mv3]}>{translate('workspace.companyCards.addNewCard.whatBankIssuesCard')}</Text> + <FormProvider + formID={ONYXKEYS.FORMS.ADD_NEW_CARD_FEED_FORM} + submitButtonText={translate('common.next')} + onSubmit={submit} + validate={validate} + style={[styles.mh5, styles.flexGrow1]} + enabledWhenOffline + > + <InputWrapper + InputComponent={TextInput} + inputID={INPUT_IDS.CARD_TITLE} + label={translate('workspace.companyCards.addNewCard.enterNameOfBank')} + role={CONST.ROLE.PRESENTATION} + defaultValue={addNewCard?.data?.cardTitle} + containerStyles={[styles.mb6]} + ref={inputCallbackRef} + /> + </FormProvider> + </ScreenWrapper> + ); +} + +CardNameStep.displayName = 'CardNameStep'; + +export default CardNameStep; diff --git a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx new file mode 100644 index 000000000000..7e469fa17093 --- /dev/null +++ b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx @@ -0,0 +1,137 @@ +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import FormHelpMessage from '@components/FormHelpMessage'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import * as Illustrations from '@components/Icon/Illustrations'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@navigation/Navigation'; +import variables from '@styles/variables'; +import * as CompanyCards from '@userActions/CompanyCards'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function CardTypeStep() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); + const [typeSelected, setTypeSelected] = useState<ValueOf<typeof CONST.COMPANY_CARDS.CARD_TYPE>>(); + const [isError, setIsError] = useState(false); + + const submit = () => { + if (!typeSelected) { + setIsError(true); + } else { + CompanyCards.setAddNewCompanyCardStepAndData({ + step: CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS, + data: { + cardType: typeSelected, + }, + isEditing: false, + }); + } + }; + + useEffect(() => { + setTypeSelected(addNewCard?.data.cardType); + }, [addNewCard?.data.cardType]); + + const handleBackButtonPress = () => { + Navigation.goBack(); + }; + + const data = [ + { + value: CONST.COMPANY_CARDS.CARD_TYPE.AMEX, + text: translate('workspace.companyCards.addNewCard.cardProviders.amex'), + keyForList: CONST.COMPANY_CARDS.CARD_TYPE.AMEX, + isSelected: typeSelected === CONST.COMPANY_CARDS.CARD_TYPE.AMEX, + leftElement: ( + <Icon + src={Illustrations.AmexBlueCompanyCards} + height={variables.iconSizeExtraLarge} + width={variables.iconSizeExtraLarge} + additionalStyles={styles.mr3} + /> + ), + }, + { + value: CONST.COMPANY_CARDS.CARD_TYPE.MASTERCARD, + text: translate('workspace.companyCards.addNewCard.cardProviders.mastercard'), + keyForList: CONST.COMPANY_CARDS.CARD_TYPE.MASTERCARD, + isSelected: typeSelected === CONST.COMPANY_CARDS.CARD_TYPE.MASTERCARD, + leftElement: ( + <Icon + src={Illustrations.MasterCardCompanyCards} + height={variables.iconSizeExtraLarge} + width={variables.iconSizeExtraLarge} + additionalStyles={styles.mr3} + /> + ), + }, + { + value: CONST.COMPANY_CARDS.CARD_TYPE.VISA, + text: translate('workspace.companyCards.addNewCard.cardProviders.visa'), + keyForList: CONST.COMPANY_CARDS.CARD_TYPE.VISA, + isSelected: typeSelected === CONST.COMPANY_CARDS.CARD_TYPE.VISA, + leftElement: ( + <Icon + src={Illustrations.VisaCompanyCards} + height={variables.iconSizeExtraLarge} + width={variables.iconSizeExtraLarge} + additionalStyles={styles.mr3} + /> + ), + }, + ]; + + return ( + <ScreenWrapper + testID={CardTypeStep.displayName} + includeSafeAreaPaddingBottom={false} + shouldEnablePickerAvoiding={false} + shouldEnableMaxHeight + > + <HeaderWithBackButton + title={translate('workspace.companyCards.addCardFeed')} + onBackButtonPress={handleBackButtonPress} + /> + + <Text style={[styles.textHeadlineLineHeightXXL, styles.ph5, styles.mv3]}>{translate('workspace.companyCards.addNewCard.yourCardProvider')}</Text> + <SelectionList + ListItem={RadioListItem} + onSelectRow={({value}) => { + setTypeSelected(value); + setIsError(false); + }} + sections={[{data}]} + shouldSingleExecuteRowSelect + initiallyFocusedOptionKey={addNewCard?.data.cardType} + shouldUpdateFocusedIndex + showConfirmButton + confirmButtonText={translate('common.next')} + onConfirm={submit} + > + {isError && ( + <View style={[styles.ph5, styles.mb3]}> + <FormHelpMessage + isError={isError} + message={translate('workspace.companyCards.addNewCard.error.pleaseSelectProvider')} + /> + </View> + )} + </SelectionList> + </ScreenWrapper> + ); +} + +CardTypeStep.displayName = 'CardTypeStep'; + +export default CardTypeStep; diff --git a/src/pages/workspace/companyCards/addNew/DetailsStep.tsx b/src/pages/workspace/companyCards/addNew/DetailsStep.tsx new file mode 100644 index 000000000000..93eccbb85a32 --- /dev/null +++ b/src/pages/workspace/companyCards/addNew/DetailsStep.tsx @@ -0,0 +1,162 @@ +import React, {useCallback} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import * as CompanyCards from '@userActions/CompanyCards'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/AddNewCardFeedForm'; + +function DetailsStep() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {inputCallbackRef} = useAutoFocusInput(); + + const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); + const feedProvider = addNewCard?.data.cardType; + const submit = () => {}; + + const handleBackButtonPress = () => { + if (feedProvider === CONST.COMPANY_CARDS.CARD_TYPE.AMEX) { + CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS}); + return; + } + CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.CARD_NAME}); + }; + + const validate = useCallback( + (values: FormOnyxValues<typeof ONYXKEYS.FORMS.ADD_NEW_CARD_FEED_FORM>): FormInputErrors<typeof ONYXKEYS.FORMS.ADD_NEW_CARD_FEED_FORM> => { + const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.BANK_ID]); + + switch (feedProvider) { + case CONST.COMPANY_CARDS.CARD_TYPE.VISA: + if (!values[INPUT_IDS.BANK_ID]) { + errors[INPUT_IDS.BANK_ID] = translate('common.error.fieldRequired'); + } + if (!values[INPUT_IDS.PROCESSOR_ID]) { + errors[INPUT_IDS.PROCESSOR_ID] = translate('common.error.fieldRequired'); + } + if (!values[INPUT_IDS.COMPANY_ID]) { + errors[INPUT_IDS.COMPANY_ID] = translate('common.error.fieldRequired'); + } + break; + case CONST.COMPANY_CARDS.CARD_TYPE.MASTERCARD: + if (!values[INPUT_IDS.DISTRIBUTION_ID]) { + errors[INPUT_IDS.DISTRIBUTION_ID] = translate('common.error.fieldRequired'); + } + break; + case CONST.COMPANY_CARDS.CARD_TYPE.AMEX: + if (!values[INPUT_IDS.DELIVERY_FILE_NAME]) { + errors[INPUT_IDS.DELIVERY_FILE_NAME] = translate('common.error.fieldRequired'); + } + break; + default: + break; + } + return errors; + }, + [feedProvider, translate], + ); + + const renderInputs = () => { + switch (feedProvider) { + case CONST.COMPANY_CARDS.CARD_TYPE.VISA: + return ( + <> + <InputWrapper + InputComponent={TextInput} + inputID={INPUT_IDS.PROCESSOR_ID} + label={translate('workspace.companyCards.addNewCard.feedDetails.visa.processorLabel')} + role={CONST.ROLE.PRESENTATION} + containerStyles={[styles.mb6]} + ref={inputCallbackRef} + /> + <InputWrapper + InputComponent={TextInput} + inputID={INPUT_IDS.BANK_ID} + label={translate('workspace.companyCards.addNewCard.feedDetails.visa.bankLabel')} + role={CONST.ROLE.PRESENTATION} + containerStyles={[styles.mb6]} + /> + <InputWrapper + InputComponent={TextInput} + inputID={INPUT_IDS.COMPANY_ID} + label={translate('workspace.companyCards.addNewCard.feedDetails.visa.companyLabel')} + role={CONST.ROLE.PRESENTATION} + containerStyles={[styles.mb6]} + /> + </> + ); + case CONST.COMPANY_CARDS.CARD_TYPE.MASTERCARD: + return ( + <InputWrapper + InputComponent={TextInput} + inputID={INPUT_IDS.DISTRIBUTION_ID} + label={translate('workspace.companyCards.addNewCard.feedDetails.mastercard.distributionLabel')} + role={CONST.ROLE.PRESENTATION} + containerStyles={[styles.mb6]} + ref={inputCallbackRef} + /> + ); + case CONST.COMPANY_CARDS.CARD_TYPE.AMEX: + return ( + <InputWrapper + InputComponent={TextInput} + inputID={INPUT_IDS.DELIVERY_FILE_NAME} + label={translate('workspace.companyCards.addNewCard.feedDetails.amex.fileNameLabel')} + role={CONST.ROLE.PRESENTATION} + containerStyles={[styles.mb6]} + ref={inputCallbackRef} + /> + ); + default: + return null; + } + }; + + return ( + <ScreenWrapper + testID={DetailsStep.displayName} + includeSafeAreaPaddingBottom={false} + shouldEnablePickerAvoiding={false} + shouldEnableMaxHeight + > + <HeaderWithBackButton + title={translate('workspace.companyCards.addCardFeed')} + onBackButtonPress={handleBackButtonPress} + /> + <ScrollView + style={styles.pt0} + contentContainerStyle={styles.flexGrow1} + > + <Text style={[styles.textHeadlineLineHeightXXL, styles.ph5, styles.mv3]}> + {feedProvider ? translate(`workspace.companyCards.addNewCard.feedDetails.${feedProvider}.title`) : ''} + </Text> + <FormProvider + formID={ONYXKEYS.FORMS.ADD_NEW_CARD_FEED_FORM} + submitButtonText={translate('common.submit')} + onSubmit={submit} + validate={validate} + style={[styles.mh5, styles.flexGrow1]} + enabledWhenOffline + > + {renderInputs()} + </FormProvider> + </ScrollView> + </ScreenWrapper> + ); +} + +DetailsStep.displayName = 'DetailsStep'; + +export default DetailsStep; diff --git a/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx index b42dcb406ada..efc149f2d2bb 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx @@ -88,7 +88,7 @@ function WorkspaceEditCardLimitPage({route}: WorkspaceEditCardLimitPageProps) { const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.LIMIT]); // We only want integers to be sent as the limit - if (!Number(values.limit) || !Number.isInteger(Number(values.limit))) { + if (!Number.isInteger(Number(values.limit))) { errors.limit = translate('iou.error.invalidAmount'); } diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx index e121de7fd159..b8b8776df048 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx @@ -15,7 +15,9 @@ import * as CardUtils from '@libs/CardUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import * as Card from '@userActions/Card'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -53,7 +55,7 @@ function WorkspaceExpensifyCardBankAccounts({route}: WorkspaceExpensifyCardBankA return eligibleBankAccounts.map((bankAccount) => { const bankName = (bankAccount.accountData?.addressName ?? '') as BankName; const bankAccountNumber = bankAccount.accountData?.accountNumber ?? ''; - const bankAccountID = bankAccount.accountData?.bankAccountID; + const bankAccountID = bankAccount.accountData?.bankAccountID ?? bankAccount?.methodID; const {icon, iconSize, iconStyles} = getBankIcon({bankName, styles}); @@ -74,26 +76,32 @@ function WorkspaceExpensifyCardBankAccounts({route}: WorkspaceExpensifyCardBankA }; return ( - <ScreenWrapper - testID={WorkspaceExpensifyCardBankAccounts.displayName} - includeSafeAreaPaddingBottom={false} - shouldEnablePickerAvoiding={false} + <AccessOrNotFoundWrapper + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} + policyID={policyID} + featureName={CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED} > - <HeaderWithBackButton - shouldShowBackButton - onBackButtonPress={() => Navigation.goBack()} - title={translate('workspace.expensifyCard.chooseBankAccount')} - /> - <View style={styles.flex1}> - <Text style={[styles.mh5, styles.mb3]}>{translate('workspace.expensifyCard.chooseExistingBank')}</Text> - {renderBankOptions()} - <MenuItem - icon={Expensicons.Plus} - title={translate('workspace.expensifyCard.addNewBankAccount')} - onPress={handleAddBankAccount} + <ScreenWrapper + testID={WorkspaceExpensifyCardBankAccounts.displayName} + includeSafeAreaPaddingBottom={false} + shouldEnablePickerAvoiding={false} + > + <HeaderWithBackButton + shouldShowBackButton + onBackButtonPress={() => Navigation.goBack()} + title={translate('workspace.expensifyCard.chooseBankAccount')} /> - </View> - </ScreenWrapper> + <View style={styles.flex1}> + <Text style={[styles.mh5, styles.mb3]}>{translate('workspace.expensifyCard.chooseExistingBank')}</Text> + {renderBankOptions()} + <MenuItem + icon={Expensicons.Plus} + title={translate('workspace.expensifyCard.addNewBankAccount')} + onPress={handleAddBankAccount} + /> + </View> + </ScreenWrapper> + </AccessOrNotFoundWrapper> ); } diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx index 4d4a2927d194..7dc6293e23ea 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx @@ -1,10 +1,12 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useCallback} from 'react'; +import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import ConfirmModal from '@components/ConfirmModal'; import FeatureList from '@components/FeatureList'; import type {FeatureListItem} from '@components/FeatureList'; import * as Illustrations from '@components/Icon/Illustrations'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -15,6 +17,7 @@ import Navigation from '@navigation/Navigation'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import * as Policy from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -35,6 +38,7 @@ const expensifyCardFeatures: FeatureListItem[] = [ translationKey: 'workspace.moreFeatures.expensifyCard.feed.features.spend', }, ]; + type WorkspaceExpensifyCardPageEmptyStateProps = { route: StackScreenProps<FullScreenNavigatorParamList, typeof SCREENS.WORKSPACE.EXPENSIFY_CARD>['route']; } & WithPolicyAndFullscreenLoadingProps; @@ -46,6 +50,7 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif const {shouldUseNarrowLayout} = useResponsiveLayout(); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false); const eligibleBankAccounts = CardUtils.getEligibleBankAccountsForCard(bankAccountList ?? {}); @@ -54,12 +59,21 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif const startFlow = useCallback(() => { if (!eligibleBankAccounts.length || isSetupUnfinished) { - Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute('', policy?.id, ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policy?.id ?? '-1'))); + Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute('new', policy?.id, ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policy?.id ?? '-1'))); } else { Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_BANK_ACCOUNT.getRoute(policy?.id ?? '-1')); } }, [eligibleBankAccounts.length, isSetupUnfinished, policy?.id]); + const confirmCurrencyChangeAndHideModal = useCallback(() => { + if (!policy) { + return; + } + Policy.updateGeneralSettings(policy.id, policy.name, CONST.CURRENCY.USD); + setIsCurrencyModalOpen(false); + startFlow(); + }, [policy, startFlow]); + return ( <WorkspacePageWithSections shouldUseScrollView @@ -67,6 +81,7 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif headerText={translate('workspace.common.expensifyCard')} route={route} guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_EXPENSIFY_CARD} + showLoadingAsFirstRender={false} shouldShowOfflineIndicatorInWideScreen > <View style={[styles.mt3, shouldUseNarrowLayout ? styles.workspaceSectionMobile : styles.workspaceSection]}> @@ -76,12 +91,29 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif subtitle={translate('workspace.moreFeatures.expensifyCard.feed.subTitle')} ctaText={translate(isSetupUnfinished ? 'workspace.expensifyCard.finishSetup' : 'workspace.expensifyCard.issueNewCard')} ctaAccessibilityLabel={translate('workspace.moreFeatures.expensifyCard.feed.ctaTitle')} - onCtaPress={startFlow} + onCtaPress={() => { + if (!Policy.isCurrencySupportedForDirectReimbursement(policy?.outputCurrency ?? '')) { + setIsCurrencyModalOpen(true); + return; + } + startFlow(); + }} illustrationBackgroundColor={theme.fallbackIconColor} illustration={Illustrations.ExpensifyCardIllustration} illustrationStyle={styles.expensifyCardIllustrationContainer} titleStyles={styles.textHeadlineH1} /> + <ConfirmModal + title={translate('workspace.common.expensifyCard')} + isVisible={isCurrencyModalOpen} + onConfirm={confirmCurrencyChangeAndHideModal} + onCancel={() => setIsCurrencyModalOpen(false)} + prompt={translate('workspace.bankAccount.updateCurrencyPrompt')} + confirmText={translate('workspace.bankAccount.updateToUSD')} + cancelText={translate('common.cancel')} + danger + /> + <Text style={[styles.textMicroSupporting, styles.m5]}>{translate('workspace.expensifyCard.disclaimer')}</Text> </View> </WorkspacePageWithSections> ); diff --git a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx index b10e1cb18e33..3b777765c7b8 100644 --- a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx @@ -2,6 +2,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React, {useEffect} from 'react'; import {useOnyx} from 'react-native-onyx'; import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import * as Card from '@userActions/Card'; @@ -29,27 +30,39 @@ function IssueNewCardPage({policy, route}: IssueNewCardPageProps) { Card.startIssueNewCardFlow(policyID); }, [policyID]); - switch (currentStep) { - case CONST.EXPENSIFY_CARD.STEP.ASSIGNEE: - return <AssigneeStep policy={policy} />; - case CONST.EXPENSIFY_CARD.STEP.CARD_TYPE: - return <CardTypeStep />; - case CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE: - return <LimitTypeStep policy={policy} />; - case CONST.EXPENSIFY_CARD.STEP.LIMIT: - return <LimitStep />; - case CONST.EXPENSIFY_CARD.STEP.CARD_NAME: - return <CardNameStep />; - case CONST.EXPENSIFY_CARD.STEP.CONFIRMATION: - return ( - <ConfirmationStep - policyID={policyID} - backTo={backTo} - /> - ); - default: - return <AssigneeStep policy={policy} />; - } + const getCurrentStep = () => { + switch (currentStep) { + case CONST.EXPENSIFY_CARD.STEP.ASSIGNEE: + return <AssigneeStep policy={policy} />; + case CONST.EXPENSIFY_CARD.STEP.CARD_TYPE: + return <CardTypeStep />; + case CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE: + return <LimitTypeStep policy={policy} />; + case CONST.EXPENSIFY_CARD.STEP.LIMIT: + return <LimitStep />; + case CONST.EXPENSIFY_CARD.STEP.CARD_NAME: + return <CardNameStep />; + case CONST.EXPENSIFY_CARD.STEP.CONFIRMATION: + return ( + <ConfirmationStep + policyID={policyID} + backTo={backTo} + /> + ); + default: + return <AssigneeStep policy={policy} />; + } + }; + + return ( + <AccessOrNotFoundWrapper + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} + policyID={policyID} + featureName={CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED} + > + {getCurrentStep()} + </AccessOrNotFoundWrapper> + ); } IssueNewCardPage.displayName = 'IssueNewCardPage'; diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index e6e17b207fc4..c947864f1029 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -23,6 +23,7 @@ import usePrevious from '@hooks/usePrevious'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; import type {SettingsNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -50,18 +51,20 @@ type WorkspaceMemberDetailsPageProps = Omit<WithPolicyAndFullscreenLoadingProps, StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.MEMBER_DETAILS>; function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceMemberDetailsPageProps) { + const policyID = route.params.policyID; + const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID); + const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {translate} = useLocalize(); const StyleUtils = useStyleUtils(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${policy?.workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`); + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`); const [isRemoveMemberConfirmModalVisible, setIsRemoveMemberConfirmModalVisible] = useState(false); const [isRoleSelectionModalVisible, setIsRoleSelectionModalVisible] = useState(false); const accountID = Number(route.params.accountID); - const policyID = route.params.policyID; const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; const prevMember = usePrevious(member); @@ -223,7 +226,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM /> ) : ( <Button - text={translate('workspace.people.removeMemberButtonTitle')} + text={translate('workspace.people.removeWorkspaceMemberButtonTitle')} onPress={askForConfirmationToRemove} medium isDisabled={isSelectedMemberOwner || isSelectedMemberCurrentUser} @@ -274,7 +277,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM {memberCards.map((card) => ( <MenuItem title={card.nameValuePairs?.cardTitle} - badgeText={CurrencyUtils.convertAmountToDisplayString(card.nameValuePairs?.unapprovedExpenseLimit)} + badgeText={CurrencyUtils.convertToDisplayString(card.nameValuePairs?.unapprovedExpenseLimit)} icon={ExpensifyCardImage} displayInDefaultIconColor iconStyles={styles.cardIcon} diff --git a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx index bf5599b6283f..8cbb9cd177e3 100644 --- a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx +++ b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx @@ -59,7 +59,9 @@ function ReportFieldsListValuesPage({ }: ReportFieldsListValuesPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout here to use the mobile selection mode on small screens only + // See https://github.com/Expensify/App/issues/48724 for more details + const {isSmallScreenWidth} = useResponsiveLayout(); const [formDraft] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT); const {selectionMode} = useMobileSelectionMode(); @@ -67,7 +69,7 @@ function ReportFieldsListValuesPage({ const [deleteValuesConfirmModalVisible, setDeleteValuesConfirmModalVisible] = useState(false); const hasAccountingConnections = PolicyUtils.hasAccountingConnections(policy); - const canSelectMultiple = !hasAccountingConnections && (shouldUseNarrowLayout ? selectionMode?.isEnabled : true); + const canSelectMultiple = !hasAccountingConnections && (isSmallScreenWidth ? selectionMode?.isEnabled : true); const [listValues, disabledListValues] = useMemo(() => { let reportFieldValues: string[]; @@ -177,7 +179,7 @@ function ReportFieldsListValuesPage({ const getHeaderButtons = () => { const options: Array<DropdownOption<DeepValueOf<typeof CONST.POLICY.BULK_ACTION_TYPES>>> = []; - if (shouldUseNarrowLayout ? selectionMode?.isEnabled : selectedValuesArray.length > 0) { + if (isSmallScreenWidth ? selectionMode?.isEnabled : selectedValuesArray.length > 0) { if (selectedValuesArray.length > 0) { options.push({ icon: Expensicons.Trashcan, @@ -259,7 +261,7 @@ function ReportFieldsListValuesPage({ customText={translate('workspace.common.selected', {selectedNumber: selectedValuesArray.length})} options={options} isSplitButton={false} - style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]} + style={[isSmallScreenWidth && styles.flexGrow1, isSmallScreenWidth && styles.mb3]} isDisabled={!selectedValuesArray.length} /> ); @@ -267,7 +269,7 @@ function ReportFieldsListValuesPage({ return ( <Button - style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]} + style={[isSmallScreenWidth && styles.flexGrow1, isSmallScreenWidth && styles.mb3]} medium success icon={Expensicons.Plus} @@ -277,7 +279,7 @@ function ReportFieldsListValuesPage({ ); }; - const selectionModeHeader = selectionMode?.isEnabled && shouldUseNarrowLayout; + const selectionModeHeader = selectionMode?.isEnabled && isSmallScreenWidth; return ( <AccessOrNotFoundWrapper @@ -302,9 +304,9 @@ function ReportFieldsListValuesPage({ Navigation.goBack(); }} > - {!shouldUseNarrowLayout && !hasAccountingConnections && getHeaderButtons()} + {!isSmallScreenWidth && !hasAccountingConnections && getHeaderButtons()} </HeaderWithBackButton> - {shouldUseNarrowLayout && <View style={[styles.pl5, styles.pr5]}>{!hasAccountingConnections && getHeaderButtons()}</View>} + {isSmallScreenWidth && <View style={[styles.pl5, styles.pr5]}>{!hasAccountingConnections && getHeaderButtons()}</View>} <View style={[styles.ph5, styles.pv4]}> <Text style={[styles.sidebarLinkText, styles.optionAlternateText]}>{translate('workspace.reportFields.listInputSubtitle')}</Text> </View> diff --git a/src/pages/workspace/reportFields/ReportFieldsValueSettingsPage.tsx b/src/pages/workspace/reportFields/ReportFieldsValueSettingsPage.tsx index 977e64c882f1..1db5d2ff2c9f 100644 --- a/src/pages/workspace/reportFields/ReportFieldsValueSettingsPage.tsx +++ b/src/pages/workspace/reportFields/ReportFieldsValueSettingsPage.tsx @@ -11,6 +11,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import Switch from '@components/Switch'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportField from '@libs/actions/Policy/ReportField'; import Navigation from '@libs/Navigation/Navigation'; @@ -58,18 +59,17 @@ function ReportFieldsValueSettingsPage({ }, [formDraft?.disabledListValues, formDraft?.listValues, policy?.fieldList, reportFieldID, valueIndex]); const hasAccountingConnections = PolicyUtils.hasAccountingConnections(policy); + const oldValueName = usePrevious(currentValueName); - if (!currentValueName || hasAccountingConnections) { + if ((!currentValueName && !oldValueName) || hasAccountingConnections) { return <NotFoundPage />; } - const deleteListValueAndHideModal = () => { if (reportFieldID) { ReportField.removeReportFieldListValue(policyID, reportFieldID, [valueIndex]); } else { ReportField.deleteReportFieldsListValue([valueIndex]); } - setIsDeleteTagModalOpen(false); Navigation.goBack(); }; @@ -99,7 +99,7 @@ function ReportFieldsValueSettingsPage({ testID={ReportFieldsValueSettingsPage.displayName} > <HeaderWithBackButton - title={currentValueName} + title={currentValueName ?? oldValueName} shouldSetModalVisibility={false} /> <ConfirmModal @@ -125,7 +125,7 @@ function ReportFieldsValueSettingsPage({ </View> </View> <MenuItemWithTopDescription - title={currentValueName} + title={currentValueName ?? oldValueName} description={translate('common.value')} shouldShowRightIcon={!reportFieldID} interactive={!reportFieldID} diff --git a/src/pages/workspace/rules/ExpenseReportRulesSection.tsx b/src/pages/workspace/rules/ExpenseReportRulesSection.tsx new file mode 100644 index 000000000000..71fdc0a29eeb --- /dev/null +++ b/src/pages/workspace/rules/ExpenseReportRulesSection.tsx @@ -0,0 +1,215 @@ +import React from 'react'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Section from '@components/Section'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import * as PolicyActions from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +type ExpenseReportRulesSectionProps = { + policyID: string; +}; + +function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policy = usePolicy(policyID); + + // Auto-approvals and self-approvals are unavailable due to the policy workflows settings + const workflowApprovalsUnavailable = PolicyUtils.getWorkflowApprovalsUnavailable(policy); + const autoPayApprovedReportsUnavailable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + + const renderFallbackSubtitle = (featureName: string) => { + return ( + <Text style={[styles.flexRow, styles.alignItemsCenter, styles.w100, styles.mt2]}> + <Text style={[styles.textNormal, styles.colorMuted]}>{translate('workspace.rules.expenseReportRules.unlockFeatureGoToSubtitle')}</Text>{' '} + <TextLink + style={styles.link} + onPress={() => Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID))} + > + {translate('workspace.common.moreFeatures').toLowerCase()} + </TextLink>{' '} + <Text style={[styles.textNormal, styles.colorMuted]}>{translate('workspace.rules.expenseReportRules.unlockFeatureEnableWorkflowsSubtitle', featureName)}</Text> + </Text> + ); + }; + + const optionItems = [ + { + title: translate('workspace.rules.expenseReportRules.customReportNamesTitle'), + subtitle: translate('workspace.rules.expenseReportRules.customReportNamesSubtitle'), + switchAccessibilityLabel: translate('workspace.rules.expenseReportRules.customReportNamesTitle'), + isActive: policy?.shouldShowCustomReportTitleOption, + pendingAction: policy?.pendingFields?.shouldShowCustomReportTitleOption, + onToggle: (isEnabled: boolean) => PolicyActions.enablePolicyDefaultReportTitle(policyID, isEnabled), + subMenuItems: [ + <OfflineWithFeedback + pendingAction={ + !policy?.pendingFields?.shouldShowCustomReportTitleOption && policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID].pendingFields?.defaultValue + ? policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID].pendingFields?.defaultValue + : null + } + > + <MenuItemWithTopDescription + key="customName" + description={translate('workspace.rules.expenseReportRules.customNameTitle')} + title={policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID].defaultValue} + shouldShowRightIcon + style={[styles.sectionMenuItemTopDescription, styles.mt6, styles.mbn3]} + onPress={() => Navigation.navigate(ROUTES.RULES_CUSTOM_NAME.getRoute(policyID))} + /> + </OfflineWithFeedback>, + <ToggleSettingOptionRow + pendingAction={ + !policy?.pendingFields?.shouldShowCustomReportTitleOption && policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID].pendingFields?.deletable + ? policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID].pendingFields?.deletable + : null + } + key="preventMembersFromChangingCustomNames" + title={translate('workspace.rules.expenseReportRules.preventMembersFromChangingCustomNamesTitle')} + switchAccessibilityLabel={translate('workspace.rules.expenseReportRules.preventMembersFromChangingCustomNamesTitle')} + wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt6]} + titleStyle={styles.pv2} + isActive={!policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID].deletable} + onToggle={(isEnabled) => PolicyActions.setPolicyPreventMemberCreatedTitle(policyID, isEnabled)} + />, + ], + }, + { + title: translate('workspace.rules.expenseReportRules.preventSelfApprovalsTitle'), + subtitle: workflowApprovalsUnavailable + ? renderFallbackSubtitle(translate('common.approvals').toLowerCase()) + : translate('workspace.rules.expenseReportRules.preventSelfApprovalsSubtitle'), + switchAccessibilityLabel: translate('workspace.rules.expenseReportRules.preventSelfApprovalsTitle'), + isActive: policy?.preventSelfApproval && !workflowApprovalsUnavailable, + disabled: workflowApprovalsUnavailable, + showLockIcon: workflowApprovalsUnavailable, + pendingAction: policy?.pendingFields?.preventSelfApproval, + onToggle: (isEnabled: boolean) => PolicyActions.setPolicyPreventSelfApproval(policyID, isEnabled), + }, + { + title: translate('workspace.rules.expenseReportRules.autoApproveCompliantReportsTitle'), + subtitle: workflowApprovalsUnavailable + ? renderFallbackSubtitle(translate('common.approvals').toLowerCase()) + : translate('workspace.rules.expenseReportRules.autoApproveCompliantReportsSubtitle'), + switchAccessibilityLabel: translate('workspace.rules.expenseReportRules.autoApproveCompliantReportsTitle'), + isActive: policy?.shouldShowAutoApprovalOptions && !workflowApprovalsUnavailable, + disabled: workflowApprovalsUnavailable, + showLockIcon: workflowApprovalsUnavailable, + pendingAction: policy?.pendingFields?.shouldShowAutoApprovalOptions, + onToggle: (isEnabled: boolean) => { + PolicyActions.enableAutoApprovalOptions(policyID, isEnabled); + }, + subMenuItems: [ + <OfflineWithFeedback + pendingAction={!policy?.pendingFields?.shouldShowAutoApprovalOptions && policy?.autoApproval?.pendingFields?.limit ? policy?.autoApproval?.pendingFields?.limit : null} + > + <MenuItemWithTopDescription + key="autoApproveReportsUnder" + description={translate('workspace.rules.expenseReportRules.autoApproveReportsUnderTitle')} + title={CurrencyUtils.convertToDisplayString( + policy?.autoApproval?.limit ?? CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS, + policy?.outputCurrency ?? CONST.CURRENCY.USD, + )} + shouldShowRightIcon + style={[styles.sectionMenuItemTopDescription, styles.mt6, styles.mbn3]} + onPress={() => Navigation.navigate(ROUTES.RULES_AUTO_APPROVE_REPORTS_UNDER.getRoute(policyID))} + /> + </OfflineWithFeedback>, + <OfflineWithFeedback + pendingAction={ + !policy?.pendingFields?.shouldShowAutoApprovalOptions && policy?.autoApproval?.pendingFields?.auditRate ? policy?.autoApproval?.pendingFields?.auditRate : null + } + > + <MenuItemWithTopDescription + key="randomReportAuditTitle" + description={translate('workspace.rules.expenseReportRules.randomReportAuditTitle')} + title={`${policy?.autoApproval?.auditRate ?? CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE}%`} + shouldShowRightIcon + style={[styles.sectionMenuItemTopDescription, styles.mt6, styles.mbn3]} + onPress={() => Navigation.navigate(ROUTES.RULES_RANDOM_REPORT_AUDIT.getRoute(policyID))} + /> + </OfflineWithFeedback>, + ], + }, + { + title: translate('workspace.rules.expenseReportRules.autoPayApprovedReportsTitle'), + subtitle: autoPayApprovedReportsUnavailable + ? renderFallbackSubtitle(translate('common.payments').toLowerCase()) + : translate('workspace.rules.expenseReportRules.autoPayApprovedReportsSubtitle'), + switchAccessibilityLabel: translate('workspace.rules.expenseReportRules.autoPayApprovedReportsTitle'), + onToggle: (isEnabled: boolean) => { + PolicyActions.enablePolicyAutoReimbursementLimit(policyID, isEnabled); + }, + disabled: autoPayApprovedReportsUnavailable, + showLockIcon: autoPayApprovedReportsUnavailable, + isActive: policy?.shouldShowAutoReimbursementLimitOption && !autoPayApprovedReportsUnavailable, + pendingAction: policy?.pendingFields?.shouldShowAutoReimbursementLimitOption, + subMenuItems: [ + <OfflineWithFeedback + pendingAction={ + !policy?.pendingFields?.shouldShowAutoReimbursementLimitOption && policy?.autoReimbursement?.pendingFields?.limit + ? policy?.autoReimbursement?.pendingFields?.limit + : null + } + > + <MenuItemWithTopDescription + key="autoPayReportsUnder" + description={translate('workspace.rules.expenseReportRules.autoPayReportsUnderTitle')} + title={CurrencyUtils.convertToDisplayString( + policy?.autoReimbursement?.limit ?? CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS, + policy?.outputCurrency ?? CONST.CURRENCY.USD, + )} + shouldShowRightIcon + style={[styles.sectionMenuItemTopDescription, styles.mt6, styles.mbn3]} + onPress={() => Navigation.navigate(ROUTES.RULES_AUTO_PAY_REPORTS_UNDER.getRoute(policyID))} + /> + </OfflineWithFeedback>, + ], + }, + ]; + + return ( + <Section + isCentralPane + title={translate('workspace.rules.expenseReportRules.title')} + subtitle={translate('workspace.rules.expenseReportRules.subtitle')} + titleStyles={styles.accountSettingsSectionTitle} + subtitleMuted + > + {optionItems.map(({title, subtitle, isActive, subMenuItems, showLockIcon, disabled, onToggle, pendingAction}, index) => { + const showBorderBottom = index !== optionItems.length - 1; + + return ( + <ToggleSettingOptionRow + key={title} + title={title} + subtitle={subtitle} + switchAccessibilityLabel={title} + wrapperStyle={[styles.pv6, showBorderBottom && styles.borderBottom]} + shouldPlaceSubtitleBelowSwitch + titleStyle={styles.pv2} + subtitleStyle={styles.pt1} + isActive={!!isActive} + showLockIcon={showLockIcon} + disabled={disabled} + subMenuItems={subMenuItems} + onToggle={onToggle} + pendingAction={pendingAction} + /> + ); + })} + </Section> + ); +} + +export default ExpenseReportRulesSection; diff --git a/src/pages/workspace/rules/PolicyRulesPage.tsx b/src/pages/workspace/rules/PolicyRulesPage.tsx index bd9e911e2a5e..1fce2fb7bfcf 100644 --- a/src/pages/workspace/rules/PolicyRulesPage.tsx +++ b/src/pages/workspace/rules/PolicyRulesPage.tsx @@ -1,7 +1,6 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; import {View} from 'react-native'; -import Section from '@components/Section'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -12,6 +11,7 @@ import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSection import * as Illustrations from '@src/components/Icon/Illustrations'; import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; +import ExpenseReportRulesSection from './ExpenseReportRulesSection'; import IndividualExpenseRulesSection from './IndividualExpenseRulesSection'; type PolicyRulesPageProps = StackScreenProps<FullScreenNavigatorParamList, typeof SCREENS.WORKSPACE.RULES>; @@ -38,16 +38,11 @@ function PolicyRulesPage({route}: PolicyRulesPageProps) { route={route} icon={Illustrations.Rules} shouldShowNotFoundPage={!canUseWorkspaceRules} + shouldShowLoading={false} > <View style={[styles.mt3, shouldUseNarrowLayout ? styles.workspaceSectionMobile : styles.workspaceSection]}> <IndividualExpenseRulesSection policyID={policyID} /> - <Section - isCentralPane - title={translate('workspace.rules.expenseReportRules.title')} - subtitle={translate('workspace.rules.expenseReportRules.subtitle')} - titleStyles={styles.accountSettingsSectionTitle} - subtitleMuted - /> + <ExpenseReportRulesSection policyID={policyID} /> </View> </WorkspacePageWithSections> </AccessOrNotFoundWrapper> diff --git a/src/pages/workspace/rules/RulesAutoApproveReportsUnderPage.tsx b/src/pages/workspace/rules/RulesAutoApproveReportsUnderPage.tsx new file mode 100644 index 000000000000..490db9a80d42 --- /dev/null +++ b/src/pages/workspace/rules/RulesAutoApproveReportsUnderPage.tsx @@ -0,0 +1,85 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {View} from 'react-native'; +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PolicyActions from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/RulesAutoApproveReportsUnderModalForm'; + +type RulesAutoApproveReportsUnderPageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER>; + +function RulesAutoApproveReportsUnderPage({route}: RulesAutoApproveReportsUnderPageProps) { + const {policyID} = route.params; + const policy = usePolicy(policyID); + + const {inputCallbackRef} = useAutoFocusInput(); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const workflowApprovalsUnavailable = PolicyUtils.getWorkflowApprovalsUnavailable(policy); + const defaultValue = CurrencyUtils.convertToFrontendAmountAsString(policy?.autoApproval?.limit ?? CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS, policy?.outputCurrency); + + return ( + <AccessOrNotFoundWrapper + policyID={route.params.policyID} + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} + featureName={CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED} + shouldBeBlocked={!policy?.shouldShowAutoApprovalOptions || workflowApprovalsUnavailable} + > + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + shouldEnableMaxHeight + testID={RulesAutoApproveReportsUnderPage.displayName} + > + <HeaderWithBackButton + title={translate('workspace.rules.expenseReportRules.autoApproveReportsUnderTitle')} + onBackButtonPress={() => Navigation.goBack()} + /> + <FormProvider + style={[styles.flexGrow1, styles.mh5, styles.mt5]} + formID={ONYXKEYS.FORMS.RULES_AUTO_APPROVE_REPORTS_UNDER_MODAL_FORM} + onSubmit={({maxExpenseAutoApprovalAmount}) => { + PolicyActions.setPolicyAutomaticApprovalLimit(policyID, maxExpenseAutoApprovalAmount); + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }} + submitButtonText={translate('common.save')} + enabledWhenOffline + > + <View style={styles.mb4}> + <InputWrapper + label={translate('iou.amount')} + InputComponent={AmountForm} + inputID={INPUT_IDS.MAX_EXPENSE_AUTO_APPROVAL_AMOUNT} + currency={CurrencyUtils.getCurrencySymbol(policy?.outputCurrency ?? CONST.CURRENCY.USD)} + defaultValue={defaultValue} + isCurrencyPressable={false} + ref={inputCallbackRef} + displayAsTextInput + /> + <Text style={[styles.mutedNormalTextLabel, styles.mt2]}>{translate('workspace.rules.expenseReportRules.autoApproveReportsUnderDescription')}</Text> + </View> + </FormProvider> + </ScreenWrapper> + </AccessOrNotFoundWrapper> + ); +} + +RulesAutoApproveReportsUnderPage.displayName = 'RulesAutoApproveReportsUnderPage'; + +export default RulesAutoApproveReportsUnderPage; diff --git a/src/pages/workspace/rules/RulesAutoPayReportsUnderPage.tsx b/src/pages/workspace/rules/RulesAutoPayReportsUnderPage.tsx new file mode 100644 index 000000000000..bc352eb5efea --- /dev/null +++ b/src/pages/workspace/rules/RulesAutoPayReportsUnderPage.tsx @@ -0,0 +1,95 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {View} from 'react-native'; +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PolicyActions from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/RulesAutoPayReportsUnderModalForm'; + +type RulesAutoPayReportsUnderPageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.RULES_AUTO_PAY_REPORTS_UNDER>; + +function RulesAutoPayReportsUnderPage({route}: RulesAutoPayReportsUnderPageProps) { + const policyID = route?.params?.policyID ?? '-1'; + const policy = usePolicy(policyID); + + const {inputCallbackRef} = useAutoFocusInput(); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const currencySymbol = CurrencyUtils.getCurrencySymbol(policy?.outputCurrency ?? CONST.CURRENCY.USD); + const autoPayApprovedReportsUnavailable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + const defaultValue = CurrencyUtils.convertToFrontendAmountAsString(policy?.autoReimbursement?.limit ?? CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS, policy?.outputCurrency); + + const validateLimit = ({maxExpenseAutoPayAmount}: FormOnyxValues<typeof ONYXKEYS.FORMS.RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM>) => { + const errors: FormInputErrors<typeof ONYXKEYS.FORMS.RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM> = {}; + if (CurrencyUtils.convertToBackendAmount(parseFloat(maxExpenseAutoPayAmount)) > CONST.POLICY.AUTO_REIMBURSEMENT_MAX_LIMIT_CENTS) { + errors[INPUT_IDS.MAX_EXPENSE_AUTO_PAY_AMOUNT] = translate('workspace.rules.expenseReportRules.autoPayApprovedReportsLimitError', currencySymbol); + } + return errors; + }; + + return ( + <AccessOrNotFoundWrapper + policyID={policyID} + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} + featureName={CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED} + shouldBeBlocked={!policy?.shouldShowAutoReimbursementLimitOption || autoPayApprovedReportsUnavailable} + > + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + shouldEnableMaxHeight + testID={RulesAutoPayReportsUnderPage.displayName} + > + <HeaderWithBackButton + title={translate('workspace.rules.expenseReportRules.autoPayReportsUnderTitle')} + onBackButtonPress={() => Navigation.goBack()} + /> + <FormProvider + style={[styles.flexGrow1, styles.mh5, styles.mt5]} + formID={ONYXKEYS.FORMS.RULES_AUTO_PAY_REPORTS_UNDER_MODAL_FORM} + validate={validateLimit} + onSubmit={({maxExpenseAutoPayAmount}) => { + PolicyActions.setPolicyAutoReimbursementLimit(policyID, maxExpenseAutoPayAmount); + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }} + submitButtonText={translate('common.save')} + enabledWhenOffline + > + <View style={styles.mb4}> + <InputWrapper + label={translate('iou.amount')} + InputComponent={AmountForm} + inputID={INPUT_IDS.MAX_EXPENSE_AUTO_PAY_AMOUNT} + currency={currencySymbol} + defaultValue={defaultValue} + isCurrencyPressable={false} + ref={inputCallbackRef} + displayAsTextInput + /> + <Text style={[styles.mutedNormalTextLabel, styles.mt2]}>{translate('workspace.rules.expenseReportRules.autoPayReportsUnderDescription')}</Text> + </View> + </FormProvider> + </ScreenWrapper> + </AccessOrNotFoundWrapper> + ); +} + +RulesAutoPayReportsUnderPage.displayName = 'RulesAutoPayReportsUnderPage'; + +export default RulesAutoPayReportsUnderPage; diff --git a/src/pages/workspace/rules/RulesCustomNamePage.tsx b/src/pages/workspace/rules/RulesCustomNamePage.tsx new file mode 100644 index 000000000000..afd8f455fee5 --- /dev/null +++ b/src/pages/workspace/rules/RulesCustomNamePage.tsx @@ -0,0 +1,111 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {View} from 'react-native'; +import BulletList from '@components/BulletList'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import TextLink from '@components/TextLink'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PolicyActions from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/RulesCustomNameModalForm'; + +type RulesCustomNamePageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.RULES_CUSTOM_NAME>; + +function RulesCustomNamePage({route}: RulesCustomNamePageProps) { + const policyID = route?.params?.policyID ?? '-1'; + const policy = usePolicy(policyID); + + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const RULE_EXAMPLE_BULLET_POINTS = [ + translate('workspace.rules.expenseReportRules.customNameEmailPhoneExample'), + translate('workspace.rules.expenseReportRules.customNameStartDateExample'), + translate('workspace.rules.expenseReportRules.customNameWorkspaceNameExample'), + translate('workspace.rules.expenseReportRules.customNameReportIDExample'), + translate('workspace.rules.expenseReportRules.customNameTotalExample'), + ] as const satisfies string[]; + + const customNameDefaultValue = policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID].defaultValue; + + const validateCustomName = ({customName}: FormOnyxValues<typeof ONYXKEYS.FORMS.RULES_CUSTOM_NAME_MODAL_FORM>) => { + const errors: FormInputErrors<typeof ONYXKEYS.FORMS.RULES_CUSTOM_NAME_MODAL_FORM> = {}; + if (!customName) { + errors[INPUT_IDS.CUSTOM_NAME] = translate('common.error.fieldRequired'); + } + return errors; + }; + + return ( + <AccessOrNotFoundWrapper + policyID={policyID} + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} + featureName={CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED} + shouldBeBlocked={!policy?.shouldShowCustomReportTitleOption} + > + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + shouldEnableMaxHeight + testID={RulesCustomNamePage.displayName} + > + <HeaderWithBackButton + title={translate('workspace.rules.expenseReportRules.customNameTitle')} + onBackButtonPress={() => Navigation.goBack()} + /> + <View style={[styles.ph5, styles.pt3, styles.pb4]}> + <Text> + {translate('workspace.rules.expenseReportRules.customNameDescription')} + <TextLink + style={[styles.link]} + href={CONST.CUSTOM_REPORT_NAME_HELP_URL} + > + {translate('workspace.rules.expenseReportRules.customNameDescriptionLink')} + </TextLink> + . + </Text> + </View> + <FormProvider + style={[styles.flexGrow1, styles.mh5]} + formID={ONYXKEYS.FORMS.RULES_CUSTOM_NAME_MODAL_FORM} + validate={validateCustomName} + onSubmit={({customName}) => { + PolicyActions.setPolicyDefaultReportTitle(policyID, customName); + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }} + submitButtonText={translate('common.save')} + enabledWhenOffline + > + <InputWrapper + InputComponent={TextInput} + inputID={INPUT_IDS.CUSTOM_NAME} + defaultValue={customNameDefaultValue} + label={translate('workspace.rules.expenseReportRules.customNameInputLabel')} + aria-label={translate('workspace.rules.expenseReportRules.customNameInputLabel')} + maxLength={CONST.WORKSPACE_NAME_CHARACTER_LIMIT} + /> + <BulletList + items={RULE_EXAMPLE_BULLET_POINTS} + header={translate('workspace.rules.expenseReportRules.examples')} + /> + </FormProvider> + </ScreenWrapper> + </AccessOrNotFoundWrapper> + ); +} + +RulesCustomNamePage.displayName = 'RulesCustomNamePage'; + +export default RulesCustomNamePage; diff --git a/src/pages/workspace/rules/RulesMaxExpenseAgePage.tsx b/src/pages/workspace/rules/RulesMaxExpenseAgePage.tsx index 514360473568..991c1f252cad 100644 --- a/src/pages/workspace/rules/RulesMaxExpenseAgePage.tsx +++ b/src/pages/workspace/rules/RulesMaxExpenseAgePage.tsx @@ -39,7 +39,8 @@ function RulesMaxExpenseAgePage({ const onChangeMaxExpenseAge = useCallback((newValue: string) => { // replace all characters that are not spaces or digits - const validMaxExpenseAge = newValue.replace(/[^0-9]/g, ''); + let validMaxExpenseAge = newValue.replace(/[^0-9]/g, ''); + validMaxExpenseAge = validMaxExpenseAge.match(/(?:\d *){1,5}/)?.[0] ?? ''; setMaxExpenseAgeValue(validMaxExpenseAge); }, []); @@ -80,7 +81,6 @@ function RulesMaxExpenseAgePage({ value={maxExpenseAgeValue} onChangeText={onChangeMaxExpenseAge} ref={inputCallbackRef} - maxLength={CONST.FORM_CHARACTER_LIMIT} /> <Text style={[styles.mutedTextLabel, styles.mt2]}>{translate('workspace.rules.individualExpenseRules.maxExpenseAgeDescription')}</Text> </View> diff --git a/src/pages/workspace/rules/RulesRandomReportAuditPage.tsx b/src/pages/workspace/rules/RulesRandomReportAuditPage.tsx new file mode 100644 index 000000000000..b127063251d3 --- /dev/null +++ b/src/pages/workspace/rules/RulesRandomReportAuditPage.tsx @@ -0,0 +1,81 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {View} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import PercentageForm from '@components/PercentageForm'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PolicyActions from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/RulesRandomReportAuditModalForm'; + +type RulesRandomReportAuditPageProps = StackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT>; + +function RulesRandomReportAuditPage({route}: RulesRandomReportAuditPageProps) { + const policyID = route?.params?.policyID ?? '-1'; + const policy = usePolicy(policyID); + + const {inputCallbackRef} = useAutoFocusInput(); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const workflowApprovalsUnavailable = PolicyUtils.getWorkflowApprovalsUnavailable(policy); + const defaultValue = policy?.autoApproval?.auditRate ?? CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE; + + return ( + <AccessOrNotFoundWrapper + policyID={policyID} + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} + featureName={CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED} + shouldBeBlocked={!policy?.shouldShowAutoApprovalOptions || workflowApprovalsUnavailable} + > + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + shouldEnableMaxHeight + testID={RulesRandomReportAuditPage.displayName} + > + <HeaderWithBackButton + title={translate('workspace.rules.expenseReportRules.randomReportAuditTitle')} + onBackButtonPress={() => Navigation.goBack()} + /> + <FormProvider + style={[styles.flexGrow1, styles.mh5, styles.mt5]} + formID={ONYXKEYS.FORMS.RULES_RANDOM_REPORT_AUDIT_MODAL_FORM} + onSubmit={({auditRatePercentage}) => { + PolicyActions.setPolicyAutomaticApprovalRate(policyID, auditRatePercentage); + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }} + submitButtonText={translate('common.save')} + enabledWhenOffline + > + <View style={styles.mb4}> + <InputWrapper + label={translate('common.percentage')} + InputComponent={PercentageForm} + defaultValue={defaultValue.toString()} + inputID={INPUT_IDS.AUDIT_RATE_PERCENTAGE} + ref={inputCallbackRef} + /> + <Text style={[styles.mutedNormalTextLabel, styles.mt2]}>{translate('workspace.rules.expenseReportRules.randomReportAuditDescription')}</Text> + </View> + </FormProvider> + </ScreenWrapper> + </AccessOrNotFoundWrapper> + ); +} + +RulesRandomReportAuditPage.displayName = 'RulesRandomReportAuditPage'; + +export default RulesRandomReportAuditPage; diff --git a/src/pages/workspace/tags/TagGLCodePage.tsx b/src/pages/workspace/tags/TagGLCodePage.tsx index 54e5c034ca07..89c281e91f4d 100644 --- a/src/pages/workspace/tags/TagGLCodePage.tsx +++ b/src/pages/workspace/tags/TagGLCodePage.tsx @@ -40,15 +40,19 @@ function TagGLCodePage({route, policyTags}: EditTagGLCodePageProps) { const {tags} = PolicyUtils.getTagList(policyTags, orderWeight); const glCode = tags?.[route.params.tagName]?.['GL Code']; + const goBack = useCallback(() => { + Navigation.goBack(ROUTES.WORKSPACE_TAG_SETTINGS.getRoute(route.params.policyID, orderWeight, tagName)); + }, [orderWeight, route.params.policyID, tagName]); + const editGLCode = useCallback( (values: FormOnyxValues<typeof ONYXKEYS.FORMS.WORKSPACE_TAG_FORM>) => { const newGLCode = values.glCode.trim(); if (newGLCode !== glCode) { Tag.setPolicyTagGLCode(route.params.policyID, tagName, orderWeight, newGLCode); } - Navigation.goBack(ROUTES.WORKSPACE_TAG_SETTINGS.getRoute(route.params.policyID, orderWeight, tagName)); + goBack(); }, - [glCode, route.params.policyID, tagName, orderWeight], + [glCode, route.params.policyID, tagName, orderWeight, goBack], ); return ( @@ -65,7 +69,7 @@ function TagGLCodePage({route, policyTags}: EditTagGLCodePageProps) { > <HeaderWithBackButton title={translate('workspace.tags.glCode')} - onBackButtonPress={() => Navigation.goBack()} + onBackButtonPress={goBack} /> <FormProvider formID={ONYXKEYS.FORMS.WORKSPACE_TAG_FORM} diff --git a/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx b/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx index 972506e31be1..d63799169ca8 100644 --- a/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx @@ -42,6 +42,9 @@ function WorkspaceEditTagsPage({route, policyTags}: WorkspaceEditTagsPageProps) if (!values[INPUT_IDS.POLICY_TAGS_NAME] && values[INPUT_IDS.POLICY_TAGS_NAME].trim() === '') { errors[INPUT_IDS.POLICY_TAGS_NAME] = translate('common.error.fieldRequired'); } + if (values[INPUT_IDS.POLICY_TAGS_NAME]?.trim() === '0') { + errors[INPUT_IDS.POLICY_TAGS_NAME] = translate('workspace.tags.invalidTagNameError'); + } if (policyTags && Object.values(policyTags).find((tag) => tag.orderWeight !== route.params.orderWeight && tag.name === values[INPUT_IDS.POLICY_TAGS_NAME])) { errors[INPUT_IDS.POLICY_TAGS_NAME] = translate('workspace.tags.existingTagError'); } diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 3cc06bede222..e86d70b11c9a 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -100,7 +100,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { required: policyTagList.required, rightElement: ( <ListItemRightCaretWithLabel - labelText={policyTagList.required ? translate('common.required') : undefined} + labelText={policyTagList.required && !!Object.values(policyTagList?.tags ?? {}).some((tag) => tag.enabled) ? translate('common.required') : undefined} shouldShowCaret={false} /> ), diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index 673b77d9c9a1..0cdb15e33ac9 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -32,6 +32,19 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { const canPerformUpgrade = !!feature && !!policy && PolicyUtils.isPolicyAdmin(policy); const isUpgraded = React.useMemo(() => PolicyUtils.isControlPolicy(policy), [policy]); + const goBack = useCallback(() => { + if (!feature) { + return; + } + switch (feature.id) { + case CONST.UPGRADE_FEATURE_INTRO_MAPPING.reportFields.id: + case CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.id: + return Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + default: + return route.params.backTo ? Navigation.navigate(route.params.backTo) : Navigation.goBack(); + } + }, [feature, policyID, route.params.backTo]); + const upgradeToCorporate = () => { if (!canPerformUpgrade) { return; @@ -47,14 +60,13 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { switch (feature.id) { case CONST.UPGRADE_FEATURE_INTRO_MAPPING.reportFields.id: Policy.enablePolicyReportFields(policyID, true, true); - return Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.id: Policy.enablePolicyRules(policyID, true, true); - return Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + break; default: - return route.params.backTo ? Navigation.navigate(route.params.backTo) : Navigation.goBack(); } - }, [feature, policyID, route.params.backTo]); + }, [feature, policyID]); useEffect(() => { const unsubscribeListener = navigation.addListener('blur', () => { @@ -79,11 +91,21 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { > <HeaderWithBackButton title={translate('common.upgrade')} - onBackButtonPress={() => (isUpgraded ? Navigation.dismissModal() : Navigation.goBack())} + onBackButtonPress={() => { + if (isUpgraded) { + Navigation.dismissModal(); + } else { + Navigation.goBack(); + } + goBack(); + }} /> {isUpgraded && ( <UpgradeConfirmation - onConfirmUpgrade={() => Navigation.dismissModal()} + onConfirmUpgrade={() => { + Navigation.dismissModal(); + goBack(); + }} policyName={policy.name} /> )} diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index db55db2514ef..c706e18c688d 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -1,14 +1,14 @@ import type {RouteProp} from '@react-navigation/native'; -import {useNavigationState} from '@react-navigation/native'; import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; import React, {forwardRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {AuthScreensParamList, BottomTabNavigatorParamList, FullScreenNavigatorParamList, ReimbursementAccountNavigatorParamList, SettingsNavigatorParamList} from '@navigation/types'; import * as Policy from '@userActions/Policy/Policy'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; type NavigatorsParamList = BottomTabNavigatorParamList & AuthScreensParamList & SettingsNavigatorParamList & ReimbursementAccountNavigatorParamList & FullScreenNavigatorParamList; @@ -57,6 +57,7 @@ function getPolicyIDFromRoute(route: PolicyRoute): string { type WithPolicyOnyxProps = { policy: OnyxEntry<OnyxTypes.Policy>; policyDraft: OnyxEntry<OnyxTypes.Policy>; + isLoadingPolicy: boolean; }; type WithPolicyProps = WithPolicyOnyxProps & { @@ -66,16 +67,21 @@ type WithPolicyProps = WithPolicyOnyxProps & { const policyDefaultProps: WithPolicyOnyxProps = { policy: {} as OnyxTypes.Policy, policyDraft: {} as OnyxTypes.Policy, + isLoadingPolicy: false, }; /* * HOC for connecting a policy in Onyx corresponding to the policyID in route params */ -export default function <TProps extends WithPolicyProps, TRef>(WrappedComponent: ComponentType<TProps & RefAttributes<TRef>>): React.ComponentType<Omit<TProps, keyof WithPolicyOnyxProps>> { - function WithPolicy(props: TProps, ref: ForwardedRef<TRef>) { - const routes = useNavigationState((state) => state.routes || []); - const currentRoute = routes?.at(-1); - const policyID = getPolicyIDFromRoute(currentRoute as PolicyRoute); +export default function <TProps extends WithPolicyProps, TRef>( + WrappedComponent: ComponentType<TProps & RefAttributes<TRef>>, +): React.ComponentType<Omit<TProps, keyof WithPolicyOnyxProps> & RefAttributes<TRef>> { + function WithPolicy(props: Omit<TProps, keyof WithPolicyOnyxProps>, ref: ForwardedRef<TRef>) { + const policyID = getPolicyIDFromRoute(props.route as PolicyRoute); + + const [policy, policyResults] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [policyDraft, policyDraftResults] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`); + const isLoadingPolicy = isLoadingOnyxValue(policyResults, policyDraftResults); if (policyID.length > 0) { Policy.updateLastAccessedWorkspace(policyID); @@ -84,7 +90,10 @@ export default function <TProps extends WithPolicyProps, TRef>(WrappedComponent: return ( <WrappedComponent // eslint-disable-next-line react/jsx-props-no-spreading - {...props} + {...(props as TProps)} + policy={policy} + policyDraft={policyDraft} + isLoadingPolicy={isLoadingPolicy} ref={ref} /> ); @@ -92,14 +101,7 @@ export default function <TProps extends WithPolicyProps, TRef>(WrappedComponent: WithPolicy.displayName = `WithPolicy`; - return withOnyx<TProps & RefAttributes<TRef>, WithPolicyOnyxProps>({ - policy: { - key: (props) => `${ONYXKEYS.COLLECTION.POLICY}${getPolicyIDFromRoute(props.route)}`, - }, - policyDraft: { - key: (props) => `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${getPolicyIDFromRoute(props.route)}`, - }, - })(forwardRef(WithPolicy)); + return forwardRef(WithPolicy); } export {policyDefaultProps}; diff --git a/src/pages/workspace/withPolicyAndFullscreenLoading.tsx b/src/pages/workspace/withPolicyAndFullscreenLoading.tsx index 2467136a382b..fd3efc3d84ef 100644 --- a/src/pages/workspace/withPolicyAndFullscreenLoading.tsx +++ b/src/pages/workspace/withPolicyAndFullscreenLoading.tsx @@ -2,7 +2,7 @@ import isEmpty from 'lodash/isEmpty'; import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; import React, {forwardRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList} from '@src/types/onyx'; @@ -27,10 +27,18 @@ export default function withPolicyAndFullscreenLoading<TProps extends WithPolicy WrappedComponent: ComponentType<TProps & RefAttributes<TRef>>, ): ComponentWithPolicyAndFullscreenLoading<TProps, TRef> { function WithPolicyAndFullscreenLoading( - {isLoadingReportData = true, policy = policyDefaultProps.policy, policyDraft = policyDefaultProps.policyDraft, ...rest}: TProps, + { + policy = policyDefaultProps.policy, + policyDraft = policyDefaultProps.policyDraft, + isLoadingPolicy = policyDefaultProps.isLoadingPolicy, + ...rest + }: Omit<TProps, keyof WithPolicyAndFullscreenLoadingOnyxProps>, ref: ForwardedRef<TRef>, ) { - if (isLoadingReportData && isEmpty(policy) && isEmpty(policyDraft)) { + const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true}); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + + if ((isLoadingPolicy || isLoadingReportData) && isEmpty(policy) && isEmpty(policyDraft)) { return <FullscreenLoadingIndicator />; } @@ -39,6 +47,7 @@ export default function withPolicyAndFullscreenLoading<TProps extends WithPolicy // eslint-disable-next-line react/jsx-props-no-spreading {...(rest as TProps)} isLoadingReportData={isLoadingReportData} + personalDetails={personalDetails} policy={policy} policyDraft={policyDraft} ref={ref} @@ -48,16 +57,7 @@ export default function withPolicyAndFullscreenLoading<TProps extends WithPolicy WithPolicyAndFullscreenLoading.displayName = `WithPolicyAndFullscreenLoading`; - return withPolicy( - withOnyx<TProps & RefAttributes<TRef>, WithPolicyAndFullscreenLoadingOnyxProps>({ - isLoadingReportData: { - key: ONYXKEYS.IS_LOADING_REPORT_DATA, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - })(forwardRef(WithPolicyAndFullscreenLoading)), - ); + return withPolicy(forwardRef(WithPolicyAndFullscreenLoading)); } export type {WithPolicyAndFullscreenLoadingProps}; diff --git a/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx index af36d79404a2..26c6275e197f 100644 --- a/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx +++ b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx @@ -1,3 +1,4 @@ +import type {ReactNode} from 'react'; import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; @@ -22,7 +23,7 @@ type ToggleSettingOptionRowProps = { customTitle?: React.ReactNode; /** Subtitle of the option */ - subtitle?: string; + subtitle?: string | ReactNode; /** Accessibility label for the switch */ switchAccessibilityLabel: string; @@ -99,7 +100,7 @@ function ToggleSettingOptionRow({ const styles = useThemeStyles(); const subtitleHtml = useMemo(() => { - if (!subtitle || !shouldParseSubtitle) { + if (!subtitle || !shouldParseSubtitle || typeof subtitle !== 'string') { return ''; } return Parser.replace(subtitle, {shouldEscapeText}); @@ -116,14 +117,18 @@ function ToggleSettingOptionRow({ }, [shouldParseSubtitle, subtitleHtml]); const subTitleView = useMemo(() => { - if (!!subtitle && shouldParseSubtitle) { - return ( - <View style={[styles.flexRow, styles.renderHTML, shouldPlaceSubtitleBelowSwitch ? styles.mt1 : {...styles.mt1, ...styles.mr5}]}> - <RenderHTML html={processedSubtitle} /> - </View> - ); + if (typeof subtitle === 'string') { + if (!!subtitle && shouldParseSubtitle) { + return ( + <View style={[styles.flexRow, styles.renderHTML, shouldPlaceSubtitleBelowSwitch ? styles.mt1 : {...styles.mt1, ...styles.mr5}]}> + <RenderHTML html={processedSubtitle} /> + </View> + ); + } + return <Text style={[styles.mutedNormalTextLabel, shouldPlaceSubtitleBelowSwitch ? styles.mt1 : {...styles.mt1, ...styles.mr5}, subtitleStyle]}>{subtitle}</Text>; } - return <Text style={[styles.mutedNormalTextLabel, shouldPlaceSubtitleBelowSwitch ? styles.mt1 : {...styles.mt1, ...styles.mr5}, subtitleStyle]}>{subtitle}</Text>; + + return subtitle; }, [ subtitle, shouldParseSubtitle, diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 9c03e5001a28..cc80705534b2 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -2,8 +2,7 @@ import {useFocusEffect} from '@react-navigation/native'; import type {StackScreenProps} from '@react-navigation/stack'; import React, {useCallback, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import ApprovalWorkflowSection from '@components/ApprovalWorkflowSection'; import ConfirmModal from '@components/ConfirmModal'; import getBankIcon from '@components/Icon/BankIcons'; @@ -23,7 +22,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getPaymentMethodDescription} from '@libs/PaymentUtils'; -import Permissions from '@libs/Permissions'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import {convertPolicyEmployeesToApprovalWorkflows, INITIAL_APPROVAL_WORKFLOW} from '@libs/WorkflowUtils'; @@ -39,29 +37,22 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {Beta} from '@src/types/onyx'; import ToggleSettingOptionRow from './ToggleSettingsOptionRow'; import type {ToggleSettingOptionRowProps} from './ToggleSettingsOptionRow'; import {getAutoReportingFrequencyDisplayNames} from './WorkspaceAutoReportingFrequencyPage'; import type {AutoReportingFrequencyKey} from './WorkspaceAutoReportingFrequencyPage'; -type WorkspaceWorkflowsPageOnyxProps = { - /** Beta features list */ - betas: OnyxEntry<Beta[]>; -}; -type WorkspaceWorkflowsPageProps = WithPolicyProps & WorkspaceWorkflowsPageOnyxProps & StackScreenProps<FullScreenNavigatorParamList, typeof SCREENS.WORKSPACE.WORKFLOWS>; +type WorkspaceWorkflowsPageProps = WithPolicyProps & StackScreenProps<FullScreenNavigatorParamList, typeof SCREENS.WORKSPACE.WORKFLOWS>; -function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPageProps) { +function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const {translate, preferredLocale} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const policyApproverEmail = policy?.approver; - const canUseAdvancedApproval = Permissions.canUseWorkflowsAdvancedApproval(betas); const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const policyApproverName = useMemo(() => PersonalDetailsUtils.getPersonalDetailByEmail(policyApproverEmail ?? '')?.displayName ?? policyApproverEmail, [policyApproverEmail]); const {approvalWorkflows, availableMembers, usedApproverEmails} = useMemo( () => convertPolicyEmployeesToApprovalWorkflows({ @@ -170,7 +161,7 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr onToggle: (isEnabled: boolean) => { Policy.setWorkspaceApprovalMode(route.params.policyID, policy?.owner ?? '', isEnabled ? CONST.POLICY.APPROVAL_MODE.BASIC : CONST.POLICY.APPROVAL_MODE.OPTIONAL); }, - subMenuItems: canUseAdvancedApproval ? ( + subMenuItems: ( <> {approvalWorkflows.map((workflow, index) => ( <OfflineWithFeedback @@ -195,17 +186,6 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr onPress={addApprovalAction} /> </> - ) : ( - <MenuItemWithTopDescription - title={policyApproverName ?? ''} - titleStyle={styles.textNormalThemeText} - descriptionTextStyle={styles.textLabelSupportingNormal} - description={translate('workflowsPage.approver')} - onPress={() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(route.params.policyID))} - shouldShowRightIcon - wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3, styles.mbn3]} - brickRoadIndicator={hasApprovalError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - /> ), isActive: ([CONST.POLICY.APPROVAL_MODE.BASIC, CONST.POLICY.APPROVAL_MODE.ADVANCED].some((approvalMode) => approvalMode === policy?.approvalMode) && !hasApprovalError) ?? false, @@ -240,7 +220,7 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr ) : ( <> {shouldShowBankAccount && ( - <View style={[styles.sectionMenuItemTopDescription, styles.mt5, styles.mbn3, styles.pb1, styles.pt1]}> + <View style={[styles.sectionMenuItemTopDescription, styles.mt5, styles.pb1, styles.pt1]}> <Text style={[styles.textLabelSupportingNormal, styles.colorMuted]}>{translate('workflowsPayerPage.paymentAccount')}</Text> </View> )} @@ -301,12 +281,10 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr translate, preferredLocale, onPressAutoReportingFrequency, - canUseAdvancedApproval, approvalWorkflows, theme.success, theme.spinner, addApprovalAction, - policyApproverName, isOffline, isPolicyAdmin, displayNameForAuthorizedPayer, @@ -374,10 +352,4 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr WorkspaceWorkflowsPage.displayName = 'WorkspaceWorkflowsPage'; -export default withPolicy( - withOnyx<WorkspaceWorkflowsPageProps, WorkspaceWorkflowsPageOnyxProps>({ - betas: { - key: ONYXKEYS.BETAS, - }, - })(WorkspaceWorkflowsPage), -); +export default withPolicy(WorkspaceWorkflowsPage); diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx index 9daf6a6a41eb..6125ac842537 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx @@ -1,8 +1,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import type {SectionListData} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import Badge from '@components/Badge'; import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -13,44 +12,29 @@ import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import InviteMemberListItem from '@components/SelectionList/InviteMemberListItem'; -import type {ListItem, Section} from '@components/SelectionList/types'; -import UserListItem from '@components/SelectionList/UserListItem'; +import type {Section} from '@components/SelectionList/types'; import Text from '@components/Text'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; -import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {FullScreenNavigatorParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; -import Permissions from '@libs/Permissions'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import variables from '@styles/variables'; -import * as Policy from '@userActions/Policy/Policy'; import * as Workflow from '@userActions/Workflow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {Beta, PolicyEmployee} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -type WorkspaceWorkflowsApprovalsApproverPageOnyxProps = { - /** Beta features list */ - // eslint-disable-next-line react/no-unused-prop-types -- This prop is used in the component - betas: OnyxEntry<Beta[]>; -}; - -type WorkspaceWorkflowsApprovalsApproverPageProps = WorkspaceWorkflowsApprovalsApproverPageOnyxProps & - WithPolicyAndFullscreenLoadingProps & +type WorkspaceWorkflowsApprovalsApproverPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps<FullScreenNavigatorParamList, typeof SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_APPROVER>; type SelectionListApprover = { @@ -64,17 +48,7 @@ type SelectionListApprover = { }; type ApproverSection = SectionListData<SelectionListApprover, Section<SelectionListApprover>>; -function WorkspaceWorkflowsApprovalsApproverPageWrapper(props: WorkspaceWorkflowsApprovalsApproverPageProps) { - if (Permissions.canUseWorkflowsAdvancedApproval(props.betas) && props.route.params.approverIndex !== undefined) { - // eslint-disable-next-line react/jsx-props-no-spreading - return <WorkspaceWorkflowsApprovalsApproverPageBeta {...props} />; - } - - // eslint-disable-next-line react/jsx-props-no-spreading - return <WorkspaceWorkflowsApprovalsApproverPage {...props} />; -} - -function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, isLoadingReportData = true, route}: WorkspaceWorkflowsApprovalsApproverPageProps) { +function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoadingReportData = true, route}: WorkspaceWorkflowsApprovalsApproverPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -96,11 +70,14 @@ function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, i setSelectedApproverEmail(currentApprover.email); }, [approvalWorkflow?.approvers, approverIndex]); + const employeeList = policy?.employeeList; + const approversFromWorkflow = approvalWorkflow?.approvers; + const isDefault = approvalWorkflow?.isDefault; const sections: ApproverSection[] = useMemo(() => { const approvers: SelectionListApprover[] = []; - if (policy?.employeeList) { - const availableApprovers = Object.values(policy.employeeList) + if (employeeList) { + const availableApprovers = Object.values(employeeList) .map((employee): SelectionListApprover | null => { const isAdmin = employee?.role === CONST.REPORT.ROLE.ADMIN; const email = employee.email; @@ -110,17 +87,17 @@ function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, i } // Do not allow the same email to be added twice - const isEmailAlreadyInApprovers = approvalWorkflow?.approvers.some((approver, index) => approver?.email === email && index !== approverIndex); + const isEmailAlreadyInApprovers = approversFromWorkflow?.some((approver, index) => approver?.email === email && index !== approverIndex); if (isEmailAlreadyInApprovers && selectedApproverEmail !== email) { return null; } // Do not allow the default approver to be added as the first approver - if (!approvalWorkflow?.isDefault && approverIndex === 0 && defaultApprover === email) { + if (!isDefault && approverIndex === 0 && defaultApprover === email) { return null; } - const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList); + const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(employeeList); const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? ''); const {avatar, displayName = email} = personalDetails?.[accountID] ?? {}; @@ -148,30 +125,21 @@ function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, i }) : approvers; + const data = OptionsListUtils.sortAlphabetically(filteredApprovers, 'text'); return [ { title: undefined, - data: OptionsListUtils.sortAlphabetically(filteredApprovers, 'text'), + data, shouldShow: true, }, ]; - }, [ - approvalWorkflow?.approvers, - approvalWorkflow?.isDefault, - approverIndex, - debouncedSearchTerm, - defaultApprover, - personalDetails, - policy?.employeeList, - selectedApproverEmail, - translate, - ]); + }, [approversFromWorkflow, isDefault, approverIndex, debouncedSearchTerm, defaultApprover, personalDetails, employeeList, selectedApproverEmail, translate]); const shouldShowListEmptyContent = !debouncedSearchTerm && approvalWorkflow && !sections[0].data.length; const nextStep = useCallback(() => { if (selectedApproverEmail) { - const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList); + const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(employeeList); const accountID = Number(policyMemberEmailsToAccountIDs[selectedApproverEmail] ?? ''); const {avatar, displayName = selectedApproverEmail} = personalDetails?.[accountID] ?? {}; Workflow.setApprovalWorkflowApprover( @@ -193,7 +161,7 @@ function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, i const firstApprover = approvalWorkflow?.approvers?.[0]?.email ?? ''; Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover)); } - }, [approvalWorkflow, approverIndex, personalDetails, policy?.employeeList, route.params.policyID, selectedApproverEmail]); + }, [approvalWorkflow, approverIndex, personalDetails, employeeList, route.params.policyID, selectedApproverEmail]); const button = useMemo(() => { let buttonText = isInitialCreationFlow ? translate('common.next') : translate('common.save'); @@ -240,6 +208,7 @@ function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, i subtitle={translate('workflowsPage.emptyContent.approverSubtitle')} subtitleStyle={styles.textSupporting} containerStyle={styles.pb10} + contentFitImage="contain" /> ), [translate, styles.textSupporting, styles.pb10], @@ -252,7 +221,7 @@ function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, i > <ScreenWrapper includeSafeAreaPaddingBottom={false} - testID={WorkspaceWorkflowsApprovalsApproverPageWrapper.displayName} + testID={WorkspaceWorkflowsApprovalsApproverPage.displayName} > <FullPageNotFoundView shouldShow={shouldShowNotFoundView} @@ -287,171 +256,6 @@ function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, i ); } -type MemberOption = Omit<ListItem, 'accountID'> & {accountID: number}; -type MembersSection = SectionListData<MemberOption, Section<MemberOption>>; - -// TODO: Remove this component when workflowsAdvancedApproval beta is removed -function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoadingReportData = true, route}: WorkspaceWorkflowsApprovalsApproverPageProps) { - const {translate} = useLocalize(); - const policyName = policy?.name ?? ''; - const [searchTerm, setSearchTerm] = useState(''); - const {isOffline} = useNetwork(); - - const isDeletedPolicyEmployee = useCallback( - (policyEmployee: PolicyEmployee) => !isOffline && policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyEmployee.errors), - [isOffline], - ); - - const [formattedPolicyEmployeeList, formattedApprover] = useMemo(() => { - const policyMemberDetails: MemberOption[] = []; - const approverDetails: MemberOption[] = []; - - const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList); - - Object.entries(policy?.employeeList ?? {}).forEach(([email, policyEmployee]) => { - if (isDeletedPolicyEmployee(policyEmployee)) { - return; - } - - const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? ''); - - const details = personalDetails?.[accountID]; - if (!details) { - Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`); - return; - } - const searchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(searchTerm); - if (searchValue.trim() && !OptionsListUtils.isSearchStringMatchUserDetails(details, searchValue)) { - return; - } - - const isOwner = policy?.owner === details.login; - const isAdmin = policyEmployee.role === CONST.POLICY.ROLE.ADMIN; - - let roleBadge = null; - if (isOwner || isAdmin) { - roleBadge = <Badge text={isOwner ? translate('common.owner') : translate('common.admin')} />; - } - - const formattedMember = { - keyForList: String(accountID), - accountID, - isSelected: policy?.approver === details.login, - isDisabled: policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(policyEmployee.errors), - text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)), - alternateText: formatPhoneNumber(details?.login ?? ''), - rightElement: roleBadge, - icons: [ - { - source: details.avatar ?? FallbackAvatar, - name: formatPhoneNumber(details?.login ?? ''), - type: CONST.ICON_TYPE_AVATAR, - id: accountID, - }, - ], - errors: policyEmployee.errors, - pendingAction: policyEmployee.pendingAction, - }; - - if (policy?.approver === details.login) { - approverDetails.push(formattedMember); - } else { - policyMemberDetails.push(formattedMember); - } - }); - return [policyMemberDetails, approverDetails]; - }, [policy?.employeeList, policy?.owner, policy?.approver, isDeletedPolicyEmployee, personalDetails, searchTerm, translate]); - - const sections: MembersSection[] = useMemo(() => { - const sectionsArray: MembersSection[] = []; - - if (searchTerm !== '') { - return [ - { - title: undefined, - data: [...formattedApprover, ...formattedPolicyEmployeeList], - shouldShow: true, - }, - ]; - } - - sectionsArray.push({ - title: undefined, - data: formattedApprover, - shouldShow: formattedApprover.length > 0, - }); - - sectionsArray.push({ - title: translate('common.all'), - data: formattedPolicyEmployeeList, - shouldShow: true, - }); - - return sectionsArray; - }, [formattedPolicyEmployeeList, formattedApprover, searchTerm, translate]); - - const headerMessage = useMemo( - () => (searchTerm && !sections[0].data.length ? translate('common.noResultsFound') : ''), - - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - [translate, sections], - ); - - const setPolicyApprover = (member: MemberOption) => { - if (!policy?.approvalMode || !personalDetails?.[member.accountID]?.login) { - return; - } - const approver: string = personalDetails?.[member.accountID]?.login ?? policy.approver ?? policy.owner; - Policy.setWorkspaceApprovalMode(policy.id, approver, policy.approvalMode); - Navigation.goBack(); - }; - - // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy); - - return ( - <AccessOrNotFoundWrapper - policyID={route.params.policyID} - featureName={CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED} - > - <ScreenWrapper - includeSafeAreaPaddingBottom={false} - testID={WorkspaceWorkflowsApprovalsApproverPageWrapper.displayName} - > - <FullPageNotFoundView - shouldShow={shouldShowNotFoundView} - subtitleKey={isEmptyObject(policy) ? undefined : 'workspace.common.notAuthorized'} - onBackButtonPress={PolicyUtils.goBackFromInvalidPolicy} - onLinkPress={PolicyUtils.goBackFromInvalidPolicy} - > - <HeaderWithBackButton - title={translate('workflowsPage.approver')} - subtitle={policyName} - onBackButtonPress={Navigation.goBack} - /> - <SelectionList - sections={sections} - textInputLabel={translate('selectionList.findMember')} - textInputValue={searchTerm} - onChangeText={setSearchTerm} - headerMessage={headerMessage} - ListItem={UserListItem} - onSelectRow={setPolicyApprover} - shouldSingleExecuteRowSelect - showScrollIndicator - /> - </FullPageNotFoundView> - </ScreenWrapper> - </AccessOrNotFoundWrapper> - ); -} - -WorkspaceWorkflowsApprovalsApproverPageWrapper.displayName = 'WorkspaceWorkflowsApprovalsApproverPage'; +WorkspaceWorkflowsApprovalsApproverPage.displayName = 'WorkspaceWorkflowsApprovalsApproverPage'; -export default withPolicyAndFullscreenLoading( - withOnyx<WorkspaceWorkflowsApprovalsApproverPageProps, WorkspaceWorkflowsApprovalsApproverPageOnyxProps>({ - betas: { - key: ONYXKEYS.BETAS, - }, - })(WorkspaceWorkflowsApprovalsApproverPageWrapper), -); +export default withPolicyAndFullscreenLoading(WorkspaceWorkflowsApprovalsApproverPage); diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx index 003c7eb00e36..2c69f07ef83b 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -188,6 +188,7 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat subtitle={translate('workflowsPage.emptyContent.expensesFromSubtitle')} subtitleStyle={styles.textSupporting} containerStyle={styles.pb10} + contentFitImage="contain" /> ), [translate, styles.textSupporting, styles.pb10], diff --git a/src/setup/platformSetup/index.desktop.ts b/src/setup/platformSetup/index.desktop.ts index 7c8227ba9f30..52c5f4cfd9d0 100644 --- a/src/setup/platformSetup/index.desktop.ts +++ b/src/setup/platformSetup/index.desktop.ts @@ -9,7 +9,6 @@ import ROUTES from '@src/ROUTES'; export default function () { AppRegistry.runApplication(Config.APP_NAME, { rootTag: document.getElementById('root'), - mode: 'legacy', }); // Send local notification when update is downloaded diff --git a/src/styles/index.ts b/src/styles/index.ts index 179e886d08ff..24eb4dd4aef6 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3,7 +3,7 @@ import type {LineLayerStyleProps} from '@rnmapbox/maps/src/utils/MapboxStyles'; import lodashClamp from 'lodash/clamp'; import type {LineLayer} from 'react-map-gl'; import type {AnimatableNumericValue, Animated, ImageStyle, TextStyle, ViewStyle} from 'react-native'; -import {StyleSheet} from 'react-native'; +import {Platform, StyleSheet} from 'react-native'; import type {CustomAnimation} from 'react-native-animatable'; import type {PickerStyle} from 'react-native-picker-select'; import type {MixedStyleDeclaration, MixedStyleRecord} from 'react-native-render-html'; @@ -71,7 +71,7 @@ type WorkspaceUpgradeIntroBoxParams = {isExtraSmallScreenWidth: boolean; isSmall type Translation = 'perspective' | 'rotate' | 'rotateX' | 'rotateY' | 'rotateZ' | 'scale' | 'scaleX' | 'scaleY' | 'translateX' | 'translateY' | 'skewX' | 'skewY' | 'matrix'; -type OfflineFeedbackStyle = Record<'deleted' | 'pending' | 'error' | 'container' | 'textContainer' | 'text' | 'errorDot', ViewStyle | TextStyle>; +type OfflineFeedbackStyle = Record<'deleted' | 'pending' | 'default' | 'error' | 'container' | 'textContainer' | 'text' | 'errorDot', ViewStyle | TextStyle>; type MapDirectionStyle = Pick<LineLayerStyleProps, 'lineColor' | 'lineWidth'>; @@ -3450,6 +3450,12 @@ const styles = (theme: ThemeColors) => pending: { opacity: 0.5, }, + default: { + // fixes a crash on iOS when we attempt to remove already unmounted children + // see https://github.com/Expensify/App/issues/48197 for more details + // it's a temporary solution while we are working on a permanent fix + opacity: Platform.OS === 'ios' ? 0.99 : undefined, + }, error: { flexDirection: 'row', alignItems: 'center', @@ -4109,11 +4115,6 @@ const styles = (theme: ThemeColors) => ...flex.justifyContentCenter, }, - taskCheckbox: { - height: 16, - width: 16, - }, - taskTitleMenuItem: { ...writingDirection.ltr, ...headlineFont, diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 81f7daf7888a..1bf1fbcbbd79 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -83,6 +83,10 @@ export default { marginVertical: 20, }, + mv6: { + marginVertical: 24, + }, + mhv5: { marginVertical: -20, }, @@ -425,6 +429,10 @@ export default { paddingVertical: 20, }, + pv6: { + paddingVertical: 24, + }, + pv10: { paddingVertical: 40, }, @@ -665,6 +673,10 @@ export default { gap: 20, }, + gap6: { + gap: 24, + }, + gap7: { gap: 28, }, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index c0c058352d00..2a84efc72814 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -251,6 +251,7 @@ export default { composerTooltipShiftHorizontal: 10, composerTooltipShiftVertical: -10, + gbrTooltipShiftHorizontal: -20, h20: 20, h28: 28, diff --git a/src/types/form/AddNewCardFeedForm.ts b/src/types/form/AddNewCardFeedForm.ts new file mode 100644 index 000000000000..95c7496d8d5a --- /dev/null +++ b/src/types/form/AddNewCardFeedForm.ts @@ -0,0 +1,28 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + CARD_TITLE: 'cardTitle', + PROCESSOR_ID: 'processorID', + BANK_ID: 'bankID', + COMPANY_ID: 'companyID', + DISTRIBUTION_ID: 'distributionID', + DELIVERY_FILE_NAME: 'deliveryFileName', +} as const; + +type InputID = ValueOf<typeof INPUT_IDS>; + +type AddNewCardFeedForm = Form< + InputID, + { + [INPUT_IDS.CARD_TITLE]: string; + [INPUT_IDS.PROCESSOR_ID]: string; + [INPUT_IDS.BANK_ID]: string; + [INPUT_IDS.COMPANY_ID]: string; + [INPUT_IDS.DISTRIBUTION_ID]: string; + [INPUT_IDS.DELIVERY_FILE_NAME]: string; + } +>; + +export type {AddNewCardFeedForm}; +export default INPUT_IDS; diff --git a/src/types/form/RulesAutoApproveReportsUnderModalForm.ts b/src/types/form/RulesAutoApproveReportsUnderModalForm.ts new file mode 100644 index 000000000000..23371595ed59 --- /dev/null +++ b/src/types/form/RulesAutoApproveReportsUnderModalForm.ts @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + MAX_EXPENSE_AUTO_APPROVAL_AMOUNT: 'maxExpenseAutoApprovalAmount', +} as const; + +type InputID = ValueOf<typeof INPUT_IDS>; + +type RulesAutoApproveReportsUnderModalForm = Form< + InputID, + { + [INPUT_IDS.MAX_EXPENSE_AUTO_APPROVAL_AMOUNT]: string; + } +>; + +export type {RulesAutoApproveReportsUnderModalForm}; +export default INPUT_IDS; diff --git a/src/types/form/RulesAutoPayReportsUnderModalForm.ts b/src/types/form/RulesAutoPayReportsUnderModalForm.ts new file mode 100644 index 000000000000..a2edbac6a294 --- /dev/null +++ b/src/types/form/RulesAutoPayReportsUnderModalForm.ts @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + MAX_EXPENSE_AUTO_PAY_AMOUNT: 'maxExpenseAutoPayAmount', +} as const; + +type InputID = ValueOf<typeof INPUT_IDS>; + +type RulesAutoPayReportsUnderModalForm = Form< + InputID, + { + [INPUT_IDS.MAX_EXPENSE_AUTO_PAY_AMOUNT]: string; + } +>; + +export type {RulesAutoPayReportsUnderModalForm}; +export default INPUT_IDS; diff --git a/src/types/form/RulesCustomNameModalForm.ts b/src/types/form/RulesCustomNameModalForm.ts new file mode 100644 index 000000000000..dfd3bdb741d3 --- /dev/null +++ b/src/types/form/RulesCustomNameModalForm.ts @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + CUSTOM_NAME: 'customName', +} as const; + +type InputID = ValueOf<typeof INPUT_IDS>; + +type RulesCustomNameModalForm = Form< + InputID, + { + [INPUT_IDS.CUSTOM_NAME]: string; + } +>; + +export type {RulesCustomNameModalForm}; +export default INPUT_IDS; diff --git a/src/types/form/RulesRandomReportAuditModalForm.ts b/src/types/form/RulesRandomReportAuditModalForm.ts new file mode 100644 index 000000000000..2362c06e37bc --- /dev/null +++ b/src/types/form/RulesRandomReportAuditModalForm.ts @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + AUDIT_RATE_PERCENTAGE: 'auditRatePercentage', +} as const; + +type InputID = ValueOf<typeof INPUT_IDS>; + +type RulesRandomReportAuditModalForm = Form< + InputID, + { + [INPUT_IDS.AUDIT_RATE_PERCENTAGE]: string; + } +>; + +export type {RulesRandomReportAuditModalForm}; +export default INPUT_IDS; diff --git a/src/types/form/SearchAdvancedFiltersForm.ts b/src/types/form/SearchAdvancedFiltersForm.ts index 7645d86c5103..a70e58d48a19 100644 --- a/src/types/form/SearchAdvancedFiltersForm.ts +++ b/src/types/form/SearchAdvancedFiltersForm.ts @@ -3,6 +3,7 @@ import type Form from './Form'; const FILTER_KEYS = { TYPE: 'type', + STATUS: 'status', DATE_AFTER: 'dateAfter', DATE_BEFORE: 'dateBefore', CURRENCY: 'currency', @@ -21,7 +22,6 @@ const FILTER_KEYS = { FROM: 'from', TO: 'to', IN: 'in', - HAS: 'has', } as const; type InputID = ValueOf<typeof FILTER_KEYS>; @@ -30,6 +30,7 @@ type SearchAdvancedFiltersForm = Form< InputID, { [FILTER_KEYS.TYPE]: string; + [FILTER_KEYS.STATUS]: string; [FILTER_KEYS.DATE_AFTER]: string; [FILTER_KEYS.DATE_BEFORE]: string; [FILTER_KEYS.CURRENCY]: string[]; @@ -48,7 +49,6 @@ type SearchAdvancedFiltersForm = Form< [FILTER_KEYS.FROM]: string[]; [FILTER_KEYS.TO]: string[]; [FILTER_KEYS.IN]: string[]; - [FILTER_KEYS.HAS]: string[]; } >; diff --git a/src/types/form/WorkspaceCategoryDescriptionHintForm.ts b/src/types/form/WorkspaceCategoryDescriptionHintForm.ts new file mode 100644 index 000000000000..4c96f080eac5 --- /dev/null +++ b/src/types/form/WorkspaceCategoryDescriptionHintForm.ts @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + COMMENT_HINT: 'commentHint', +} as const; + +type InputID = ValueOf<typeof INPUT_IDS>; + +type WorkspaceCategoryDescriptionHintForm = Form< + InputID, + { + [INPUT_IDS.COMMENT_HINT]: string; + } +>; + +export type {WorkspaceCategoryDescriptionHintForm}; +export default INPUT_IDS; diff --git a/src/types/form/WorkspaceCategoryFlagAmountsOverForm.ts b/src/types/form/WorkspaceCategoryFlagAmountsOverForm.ts new file mode 100644 index 000000000000..e5418cdb8da1 --- /dev/null +++ b/src/types/form/WorkspaceCategoryFlagAmountsOverForm.ts @@ -0,0 +1,20 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + MAX_EXPENSE_AMOUNT: 'maxExpenseAmount', + EXPENSE_LIMIT_TYPE: 'expenseLimitType', +} as const; + +type InputID = ValueOf<typeof INPUT_IDS>; + +type WorkspaceCategoryFlagAmountsOverForm = Form< + InputID, + { + [INPUT_IDS.MAX_EXPENSE_AMOUNT]: string; + [INPUT_IDS.EXPENSE_LIMIT_TYPE]: string; + } +>; + +export type {WorkspaceCategoryFlagAmountsOverForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index 94b6a4ec08a4..461de119dd95 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -12,6 +12,7 @@ export type {HomeAddressForm} from './HomeAddressForm'; export type {IKnowTeacherForm} from './IKnowTeacherForm'; export type {IntroSchoolPrincipalForm} from './IntroSchoolPrincipalForm'; export type {IssueNewExpensifyCardForm} from './IssueNewExpensifyCardForm'; +export type {AddNewCardFeedForm} from './AddNewCardFeedForm'; export type {EditExpensifyCardNameForm} from './EditExpensifyCardNameForm'; export type {LegalNameForm} from './LegalNameForm'; export type {MoneyRequestAmountForm} from './MoneyRequestAmountForm'; @@ -71,7 +72,13 @@ export type {SearchAdvancedFiltersForm} from './SearchAdvancedFiltersForm'; export type {EditExpensifyCardLimitForm} from './EditExpensifyCardLimitForm'; export type {default as TextPickerModalForm} from './TextPickerModalForm'; export type {default as Form} from './Form'; +export type {RulesCustomNameModalForm} from './RulesCustomNameModalForm'; +export type {RulesAutoApproveReportsUnderModalForm} from './RulesAutoApproveReportsUnderModalForm'; +export type {RulesRandomReportAuditModalForm} from './RulesRandomReportAuditModalForm'; +export type {RulesAutoPayReportsUnderModalForm} from './RulesAutoPayReportsUnderModalForm'; export type {RulesRequiredReceiptAmountForm} from './RulesRequiredReceiptAmountForm'; export type {RulesMaxExpenseAmountForm} from './RulesMaxExpenseAmountForm'; export type {RulesMaxExpenseAgeForm} from './RulesMaxExpenseAgeForm'; +export type {WorkspaceCategoryDescriptionHintForm} from './WorkspaceCategoryDescriptionHintForm'; +export type {WorkspaceCategoryFlagAmountsOverForm} from './WorkspaceCategoryFlagAmountsOverForm'; export type {WorkspaceCompanyCardFeedName} from './WorkspaceCompanyCardFeedName'; diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts index 4edb029d02e0..b856ed9010dd 100644 --- a/src/types/onyx/Account.ts +++ b/src/types/onyx/Account.ts @@ -1,6 +1,5 @@ import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import type DismissedReferralBanners from './DismissedReferralBanners'; import type * as OnyxCommon from './OnyxCommon'; @@ -11,16 +10,25 @@ type TwoFactorAuthStep = ValueOf<typeof CONST.TWO_FACTOR_AUTH_STEPS> | ''; type DelegateRole = ValueOf<typeof CONST.DELEGATE_ROLE>; /** Model of delegate */ -type Delegate = { +type Delegate = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** The email of the delegate */ email: string; /** The role of the delegate */ - role: DelegateRole; + role?: DelegateRole; - /** Authentication failure errors */ - error?: TranslationPaths; -}; + /** Whether the user validation code was sent */ + validateCodeSent?: boolean; + + /** Field-specific server side errors keyed by microtime */ + errorFields?: OnyxCommon.ErrorFields; + + /** Whether the user is loading */ + isLoading?: boolean; + + /** The accountID of a delegate when they aren't in the personalDetails. */ + optimisticAccountID?: number; +}>; /** Model of delegated access data */ type DelegatedAccess = { @@ -34,7 +42,7 @@ type DelegatedAccess = { delegate?: string; /** Authentication failure errors when disconnecting as a copilot */ - error?: TranslationPaths; + errorFields?: OnyxCommon.ErrorFields; }; /** Model of user account */ @@ -125,4 +133,4 @@ type Account = { }; export default Account; -export type {TwoFactorAuthStep, DelegateRole, DelegatedAccess}; +export type {TwoFactorAuthStep, DelegateRole, DelegatedAccess, Delegate}; diff --git a/src/types/onyx/CardFeeds.ts b/src/types/onyx/CardFeeds.ts index 59d93a192cf8..35f69ded32e9 100644 --- a/src/types/onyx/CardFeeds.ts +++ b/src/types/onyx/CardFeeds.ts @@ -1,3 +1,6 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + /** Card feed data */ type CardFeedData = { /** Whether any actions are pending */ @@ -31,4 +34,32 @@ type CardFeeds = { companyCardNicknames: Record<string, string>; }; +/** Data required to be sent to add a new card */ +type AddNewCardFeedData = { + /** The email address of the cardholder */ + assigneeEmail: string; + + /** Card type */ + cardType: ValueOf<typeof CONST.COMPANY_CARDS.CARD_TYPE>; + + /** Name of the card */ + cardTitle: string; +}; + +/** Issue new card flow steps */ +type AddNewCardFeedStep = ValueOf<typeof CONST.COMPANY_CARDS.STEP>; + +/** Model of Issue new card flow */ +type AddNewCompanyCardFeed = { + /** The current step of the flow */ + currentStep: AddNewCardFeedStep; + + /** Data required to be sent to issue a new card */ + data: AddNewCardFeedData; + + /** Whether the user is editing step */ + isEditing: boolean; +}; + export default CardFeeds; +export type {AddNewCardFeedStep, AddNewCompanyCardFeed, AddNewCardFeedData}; diff --git a/src/types/onyx/IntroSelected.ts b/src/types/onyx/IntroSelected.ts index 6850f651ca2a..0e1b4ec60ae4 100644 --- a/src/types/onyx/IntroSelected.ts +++ b/src/types/onyx/IntroSelected.ts @@ -1,9 +1,15 @@ -import type {OnboardingPurposeType} from '@src/CONST'; +import type {OnboardingInviteType, OnboardingPurposeType} from '@src/CONST'; /** Model of onboarding */ -type IntroSelected = { +type IntroSelected = Partial<{ /** The choice that the user selected in the engagement modal */ choice: OnboardingPurposeType; -}; + + /** The invite type */ + inviteType: OnboardingInviteType; + + /** Whether the onboarding is complete */ + isInviteOnboardingComplete: boolean; +}>; export default IntroSelected; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index fcab54b470bf..500ac73fd9a4 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -8,7 +8,7 @@ import type ReportActionName from './ReportActionName'; type JoinWorkspaceResolution = ValueOf<typeof CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION>; /** Types of payments methods */ -type PaymentMethodType = DeepValueOf<typeof CONST.IOU.PAYMENT_TYPE | typeof CONST.IOU.REPORT_ACTION_TYPE | typeof CONST.WALLET.TRANSFER_METHOD_TYPE | typeof CONST.PAYMENT_METHODS>; +type PaymentMethodType = DeepValueOf<typeof CONST.IOU.PAYMENT_TYPE | typeof CONST.IOU.REPORT_ACTION_TYPE | typeof CONST.WALLET.TRANSFER_METHOD_TYPE>; /** Types of sources of original message */ type OriginalMessageSource = 'Chronos' | 'email' | 'ios' | 'android' | 'web' | ''; @@ -175,6 +175,9 @@ type OriginalMessageClosed = { /** If the report was closed because accounts got merged, then this is the old account ID */ oldAccountID?: number; + + /** Name of the invoice receiver's policy */ + receiverPolicyName?: string; }; /** Model of `renamed` report action, created when chat rooms get renamed */ @@ -491,6 +494,15 @@ type OriginalMessageUnapproved = { expenseReportID: string; }; +/** Model of `Removed From Approval Chain` report action */ +type OriginalMessageRemovedFromApprovalChain = { + /** The submitter IDs whose approval chains changed such that the approver was removed from their approval chains */ + submittersAccountIDs: number[]; + + /** The accountID of the approver who was removed from the submitter's approval chain */ + whisperedTo: number[]; +}; + /** * Model of `Add payment card` report action */ @@ -557,8 +569,9 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED]: OriginalMessageReimbursementDequeued; [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DELAYED]: never; [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_QUEUED]: OriginalMessageReimbursementQueued; - [CONST.REPORT.ACTIONS.TYPE.RENAMED]: OriginalMessageRenamed; [CONST.REPORT.ACTIONS.TYPE.REJECTED]: never; + [CONST.REPORT.ACTIONS.TYPE.REMOVED_FROM_APPROVAL_CHAIN]: OriginalMessageRemovedFromApprovalChain; + [CONST.REPORT.ACTIONS.TYPE.RENAMED]: OriginalMessageRenamed; [CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW]: OriginalMessageReportPreview; [CONST.REPORT.ACTIONS.TYPE.SELECTED_FOR_RANDOM_AUDIT]: never; [CONST.REPORT.ACTIONS.TYPE.SHARE]: never; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index d1e66770c370..f2604d723f05 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1370,6 +1370,48 @@ type PendingJoinRequestPolicy = { >; }; +/** Data informing when a given rule should be applied */ +type ApplyRulesWhen = { + /** The condition for applying the rule to the workspace */ + condition: 'matches'; + + /** The target field to which the rule is applied */ + field: 'category'; + + /** The value of the target field */ + value: string; +}; + +/** Approval rule data model */ +type ApprovalRule = { + /** The approver's email */ + approver: string; + + /** Set of conditions under which the approval rule should be applied */ + applyWhen: ApplyRulesWhen[]; + + /** An id of the rule */ + id?: string; +}; + +/** Expense rule data model */ +type ExpenseRule = { + /** Object containing information about the tax field id and its external identifier */ + tax: { + /** Object wrapping the external tax id */ + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + /** The external id of the tax field. */ + externalID: string; + }; + }; + /** Set of conditions under which the expense rule should be applied */ + applyWhen: ApplyRulesWhen[]; + + /** An id of the rule */ + id?: string; +}; + /** Model of policy data */ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< { @@ -1446,9 +1488,45 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** The reimbursement choice for policy */ reimbursementChoice?: ValueOf<typeof CONST.POLICY.REIMBURSEMENT_CHOICES>; - /** The maximum report total allowed to trigger auto reimbursement. */ + /** Detailed settings for the autoReimbursement */ + autoReimbursement?: OnyxCommon.OnyxValueWithOfflineFeedback< + { + /** + * The maximum report total allowed to trigger auto reimbursement. + */ + limit?: number; + }, + 'limit' + >; + + /** The maximum report total allowed to trigger auto reimbursement */ autoReimbursementLimit?: number; + /** + * Whether the auto-approval options are enabled in the policy rules + */ + shouldShowAutoApprovalOptions?: boolean; + + /** Detailed settings for the autoApproval */ + autoApproval?: OnyxCommon.OnyxValueWithOfflineFeedback< + { + /** + * The maximum report total allowed to trigger auto approval. + */ + limit?: number; + /** + * Percentage of the reports that should be selected for a random audit + */ + auditRate?: number; + }, + 'limit' | 'auditRate' + >; + + /** + * Whether the custom report name options are enabled in the policy rules + */ + shouldShowCustomReportTitleOption?: boolean; + /** Whether to leave the calling account as an admin on the policy */ makeMeAdmin?: boolean; @@ -1507,6 +1585,15 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Collection of tax rates attached to a policy */ taxRates?: TaxRatesWithDefault; + /** A set of rules related to the workpsace */ + rules?: OnyxCommon.OnyxValueWithOfflineFeedback<{ + /** A set of rules related to the workpsace approvals */ + approvalRules?: ApprovalRule[]; + + /** A set of rules related to the workpsace expenses */ + expenseRules?: ExpenseRule[]; + }>; + /** ReportID of the admins room for this workspace */ chatReportIDAdmins?: number; @@ -1517,7 +1604,7 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< connections?: Connections; /** Report fields attached to the policy */ - fieldList?: Record<string, OnyxCommon.OnyxValueWithOfflineFeedback<PolicyReportField>>; + fieldList?: Record<string, OnyxCommon.OnyxValueWithOfflineFeedback<PolicyReportField, 'defaultValue' | 'deletable'>>; /** Whether the Categories feature is enabled */ areCategoriesEnabled?: boolean; @@ -1588,6 +1675,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether GL codes are enabled */ glCodes?: boolean; + /** Is the auto-pay option for the policy enabled */ + shouldShowAutoReimbursementLimitOption?: boolean; + /** Policy MCC Group settings */ mccGroup?: Record<string, MccGroup>; @@ -1667,5 +1757,7 @@ export type { SageIntacctConnectionsConfig, SageIntacctExportConfig, ACHAccount, + ApprovalRule, + ExpenseRule, NetSuiteConnectionConfig, }; diff --git a/src/types/onyx/PolicyCategory.ts b/src/types/onyx/PolicyCategory.ts index 18a714208819..ed2638f95491 100644 --- a/src/types/onyx/PolicyCategory.ts +++ b/src/types/onyx/PolicyCategory.ts @@ -1,5 +1,8 @@ import type * as OnyxCommon from './OnyxCommon'; +/** The type of policy category expense limit */ +type PolicyCategoryExpenseLimitType = 'expense' | 'daily'; + /** Model of policy category */ type PolicyCategory = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Name of a category */ @@ -33,9 +36,21 @@ type PolicyCategory = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** A list of errors keyed by microtime */ errors?: OnyxCommon.Errors | null; + + /** A description hint related to the category */ + commentHint?: string; + + /** Maximum amount allowed for an expense in this category */ + maxExpenseAmount?: number; + + /** The type of expense limit associated with this category */ + expenseLimitType?: PolicyCategoryExpenseLimitType; + + /** Max expense amount with no receipt violation */ + maxExpenseAmountNoReceipt?: number | null; }>; /** Record of policy categories, indexed by their name */ type PolicyCategories = Record<string, PolicyCategory>; -export type {PolicyCategory, PolicyCategories}; +export type {PolicyCategory, PolicyCategories, PolicyCategoryExpenseLimitType}; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 0a545cb8e4dc..b92f38f29ee1 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -39,7 +39,7 @@ type PendingChatMember = { /** Report participant properties */ type Participant = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Whether the participant is visible in the report */ - hidden?: boolean; + notificationPreference: NotificationPreference; /** What is the role of the participant in the report */ role?: 'admin' | 'member'; @@ -113,9 +113,6 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< /** The time of the last mention of the report */ lastMentionedTime?: string | null; - /** The current user's notification preference for this report */ - notificationPreference?: NotificationPreference; - /** The policy avatar to use, if any */ policyAvatar?: string | null; diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index e67b5b30f1d6..98e3aa66fa00 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -1,18 +1,29 @@ import type {ValueOf} from 'type-fest'; import type {SearchStatus} from '@components/Search/types'; +import type ChatListItem from '@components/SelectionList/ChatListItem'; import type ReportListItem from '@components/SelectionList/Search/ReportListItem'; import type TransactionListItem from '@components/SelectionList/Search/TransactionListItem'; -import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import type CONST from '@src/CONST'; +import type ONYXKEYS from '@src/ONYXKEYS'; +import type ReportActionName from './ReportActionName'; /** Types of search data */ type SearchDataTypes = ValueOf<typeof CONST.SEARCH.DATA_TYPES>; /** Model of search result list item */ -type ListItemType<T extends SearchStatus> = T extends typeof CONST.SEARCH.STATUS.EXPENSE.ALL ? typeof TransactionListItem : typeof ReportListItem; +type ListItemType<C extends SearchDataTypes, T extends SearchStatus> = C extends typeof CONST.SEARCH.DATA_TYPES.CHAT + ? typeof ChatListItem + : T extends typeof CONST.SEARCH.STATUS.EXPENSE.ALL + ? typeof TransactionListItem + : typeof ReportListItem; /** Model of search list item data type */ -type ListItemDataType<T extends SearchStatus> = T extends typeof CONST.SEARCH.STATUS.EXPENSE.ALL ? TransactionListItemType[] : ReportListItemType[]; +type ListItemDataType<C extends SearchDataTypes, T extends SearchStatus> = C extends typeof CONST.SEARCH.DATA_TYPES.CHAT + ? ReportActionListItemType[] + : T extends typeof CONST.SEARCH.STATUS.EXPENSE.ALL + ? TransactionListItemType[] + : ReportListItemType[]; /** Model of columns to show for search results */ type ColumnsToShow = { @@ -98,6 +109,39 @@ type SearchReport = { action?: SearchTransactionAction; }; +/** Model of report action search result */ +type SearchReportAction = { + /** The report action sender ID */ + accountID: number; + + /** The name (or type) of the action */ + actionName: ReportActionName; + + /** The report action created date */ + created: string; + + /** report action message */ + message: Array<{ + /** The type of the action item fragment. Used to render a corresponding component */ + type: string; + + /** The text content of the fragment. */ + text: string; + + /** The html content of the fragment. */ + html: string; + + /** Collection of accountIDs of users mentioned in message */ + whisperedTo?: number[]; + }>; + + /** The ID of the report action */ + reportActionID: string; + + /** The ID of the report */ + reportID: string; +}; + /** Model of transaction search result */ type SearchTransaction = { /** The ID of the transaction */ @@ -212,13 +256,23 @@ type SearchTransaction = { /** Types of searchable transactions */ type SearchTransactionType = ValueOf<typeof CONST.SEARCH.TRANSACTION_TYPE>; +/** + * A utility type that creates a record where all keys are strings that start with a specified prefix. + */ +type PrefixedRecord<Prefix extends string, ValueType> = { + [Key in `${Prefix}${string}`]: ValueType; +}; + /** Model of search results */ type SearchResults = { /** Current search results state */ search: SearchResultsInfo; /** Search results data */ - data: Record<string, SearchTransaction & Record<string, SearchPersonalDetails>> & Record<string, SearchReport>; + data: PrefixedRecord<typeof ONYXKEYS.COLLECTION.TRANSACTION, SearchTransaction> & + Record<typeof ONYXKEYS.PERSONAL_DETAILS_LIST, Record<string, SearchPersonalDetails>> & + PrefixedRecord<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS, Record<string, SearchReportAction>> & + PrefixedRecord<typeof ONYXKEYS.COLLECTION.REPORT, SearchReport>; /** Whether search data is being fetched from server */ isLoading?: boolean; @@ -226,4 +280,4 @@ type SearchResults = { export default SearchResults; -export type {ListItemType, ListItemDataType, SearchTransaction, SearchTransactionType, SearchTransactionAction, SearchPersonalDetails, SearchDataTypes, SearchReport}; +export type {ListItemType, ListItemDataType, SearchTransaction, SearchTransactionType, SearchTransactionAction, SearchPersonalDetails, SearchDataTypes, SearchReport, SearchReportAction}; diff --git a/src/types/onyx/ValidateMagicCodeAction.ts b/src/types/onyx/ValidateMagicCodeAction.ts new file mode 100644 index 000000000000..a1780b471fc4 --- /dev/null +++ b/src/types/onyx/ValidateMagicCodeAction.ts @@ -0,0 +1,18 @@ +import type * as OnyxCommon from './OnyxCommon'; + +/** Model of action to receive magic code */ +type ValidateMagicCodeAction = OnyxCommon.OnyxValueWithOfflineFeedback< + { + /** Whether the user validation code was sent */ + validateCodeSent?: boolean; + + /** Field-specific server side errors keyed by microtime */ + errorFields?: OnyxCommon.ErrorFields; + + /** Whether the magic code is sending */ + isLoading?: boolean; + }, + 'actionVerified' +>; + +export default ValidateMagicCodeAction; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 0073e47bb65c..a44aacfb679f 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -12,6 +12,7 @@ import type CancellationDetails from './CancellationDetails'; import type Card from './Card'; import type {CardList, IssueNewCard, WorkspaceCardsList} from './Card'; import type CardFeeds from './CardFeeds'; +import type {AddNewCompanyCardFeed} from './CardFeeds'; import type {CapturedLogs, Log} from './Console'; import type Credentials from './Credentials'; import type Currency from './Currency'; @@ -96,6 +97,7 @@ import type User from './User'; import type UserLocation from './UserLocation'; import type UserMetadata from './UserMetadata'; import type UserWallet from './UserWallet'; +import type ValidateMagicCodeAction from './ValidateMagicCodeAction'; import type WalletAdditionalDetails from './WalletAdditionalDetails'; import type {WalletAdditionalQuestionDetails} from './WalletAdditionalDetails'; import type WalletOnfido from './WalletOnfido'; @@ -130,6 +132,7 @@ export type { IntroSelected, IOU, IssueNewCard, + AddNewCompanyCardFeed, LastExportMethod, Locale, Login, @@ -228,4 +231,5 @@ export type { WorkspaceTooltip, CardFeeds, ImportedSpreadsheet, + ValidateMagicCodeAction, }; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 8a7095af6f7f..662eae8d7b21 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -38,16 +38,16 @@ jest.mock('@src/libs/Navigation/isSearchTopmostCentralPane', () => jest.fn()); const CARLOS_EMAIL = 'cmartins@expensifail.com'; const CARLOS_ACCOUNT_ID = 1; -const CARLOS_PARTICIPANT: Participant = {hidden: false, role: 'member'}; +const CARLOS_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; const JULES_EMAIL = 'jules@expensifail.com'; const JULES_ACCOUNT_ID = 2; -const JULES_PARTICIPANT: Participant = {hidden: false, role: 'member'}; +const JULES_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; const RORY_EMAIL = 'rory@expensifail.com'; const RORY_ACCOUNT_ID = 3; -const RORY_PARTICIPANT: Participant = {hidden: false, role: 'admin'}; +const RORY_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'admin'}; const VIT_EMAIL = 'vit@expensifail.com'; const VIT_ACCOUNT_ID = 4; -const VIT_PARTICIPANT: Participant = {hidden: false, role: 'member'}; +const VIT_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; OnyxUpdateManager(); describe('actions/IOU', () => { @@ -102,7 +102,10 @@ describe('actions/IOU', () => { iouReportID = iouReport?.reportID; transactionThread = transactionThreadReport; - expect(iouReport?.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + expect(iouReport?.participants).toEqual({ + [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + }); // They should be linked together expect(chatReport?.participants).toEqual({[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}); @@ -295,7 +298,10 @@ describe('actions/IOU', () => { const iouReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); iouReportID = iouReport?.reportID; - expect(iouReport?.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + expect(iouReport?.participants).toEqual({ + [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + }); // They should be linked together expect(chatReport.iouReportID).toBe(iouReportID); @@ -640,10 +646,10 @@ describe('actions/IOU', () => { const iouReport = iouReports[0]; iouReportID = iouReport?.reportID; - expect(iouReport?.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + expect(chatReport?.participants).toStrictEqual({[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}); // They should be linked together - expect(chatReport?.participants).toEqual({[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}); + expect(chatReport?.participants).toStrictEqual({[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}); expect(chatReport?.iouReportID).toBe(iouReport?.reportID); resolve(); @@ -1161,14 +1167,15 @@ describe('actions/IOU', () => { // The 1:1 chat reports and the IOU reports should be linked together expect(carlosChatReport?.iouReportID).toBe(carlosIOUReport?.reportID); expect(carlosIOUReport?.chatReportID).toBe(carlosChatReport?.reportID); - expect(carlosIOUReport?.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + Object.values(carlosIOUReport?.participants ?? {}).forEach((participant) => { + expect(participant.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + }); expect(julesChatReport?.iouReportID).toBe(julesIOUReport?.reportID); expect(julesIOUReport?.chatReportID).toBe(julesChatReport?.reportID); expect(vitChatReport?.iouReportID).toBe(vitIOUReport?.reportID); expect(vitIOUReport?.chatReportID).toBe(vitChatReport?.reportID); - expect(carlosIOUReport?.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); resolve(); }, @@ -2439,7 +2446,7 @@ describe('actions/IOU', () => { // Given a transaction thread thread = ReportUtils.buildTransactionThread(createIOUAction, iouReport); - expect(thread.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + expect(thread.participants).toStrictEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, @@ -2628,7 +2635,7 @@ describe('actions/IOU', () => { // Given a transaction thread thread = ReportUtils.buildTransactionThread(createIOUAction, iouReport); - expect(thread.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + expect(thread.participants).toEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(participantAccountIDs); @@ -2716,7 +2723,7 @@ describe('actions/IOU', () => { jest.advanceTimersByTime(10); thread = ReportUtils.buildTransactionThread(createIOUAction, iouReport); - expect(thread.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + expect(thread.participants).toStrictEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, @@ -2956,7 +2963,7 @@ describe('actions/IOU', () => { jest.advanceTimersByTime(10); thread = ReportUtils.buildTransactionThread(createIOUAction, iouReport); - expect(thread.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + expect(thread.participants).toStrictEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index a9af7b9da7d8..01842b48b6f4 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -12,7 +12,9 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const ESH_EMAIL = 'eshgupta1217@gmail.com'; const ESH_ACCOUNT_ID = 1; -const ESH_PARTICIPANT: Participant = {hidden: false}; +const ESH_PARTICIPANT_ANNOUNCE_ROOM: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}; +const ESH_PARTICIPANT_ADMINS_ROOM: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}; +const ESH_PARTICIPANT_EXPENSE_CHAT = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}; const WORKSPACE_NAME = "Esh's Workspace"; OnyxUpdateManager(); @@ -78,17 +80,19 @@ describe('actions/Policy', () => { expect(workspaceReports.length).toBe(3); workspaceReports.forEach((report) => { expect(report?.pendingFields?.addWorkspaceRoom).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(report?.participants).toEqual({[ESH_ACCOUNT_ID]: ESH_PARTICIPANT}); switch (report?.chatType) { case CONST.REPORT.CHAT_TYPE.POLICY_ADMINS: { + expect(report?.participants).toEqual({[ESH_ACCOUNT_ID]: ESH_PARTICIPANT_ADMINS_ROOM}); adminReportID = report.reportID; break; } case CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE: { + expect(report?.participants).toEqual({[ESH_ACCOUNT_ID]: ESH_PARTICIPANT_ANNOUNCE_ROOM}); announceReportID = report.reportID; break; } case CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT: { + expect(report?.participants).toEqual({[ESH_ACCOUNT_ID]: ESH_PARTICIPANT_EXPENSE_CHAT}); expenseReportID = report.reportID; break; } diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index e6ab31334bb1..a099348257f0 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -103,7 +103,11 @@ describe('actions/Report', () => { key: `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, value: { reportID: REPORT_ID, - notificationPreference: 'always', + participants: { + [TEST_USER_ACCOUNT_ID]: { + notificationPreference: 'always', + }, + }, lastVisibleActionCreated: '2022-11-22 03:48:27.267', lastMessageText: 'Testing a comment', lastActorAccountID: TEST_USER_ACCOUNT_ID, @@ -230,7 +234,11 @@ describe('actions/Report', () => { key: `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, value: { reportID: REPORT_ID, - notificationPreference: 'always', + participants: { + [USER_1_ACCOUNT_ID]: { + notificationPreference: 'always', + }, + }, lastMessageText: 'Comment 1', lastActorAccountID: USER_2_ACCOUNT_ID, lastVisibleActionCreated: reportActionCreatedDate, @@ -380,7 +388,11 @@ describe('actions/Report', () => { key: `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, value: { reportID: REPORT_ID, - notificationPreference: 'always', + participants: { + [USER_1_ACCOUNT_ID]: { + notificationPreference: 'always', + }, + }, lastMessageText: 'Current User Comment 3', lastActorAccountID: 1, lastVisibleActionCreated: reportActionCreatedDate, diff --git a/tests/e2e/TestSpec.yml b/tests/e2e/TestSpec.yml index 277a4d1ef43b..563a7eb4ce0e 100644 --- a/tests/e2e/TestSpec.yml +++ b/tests/e2e/TestSpec.yml @@ -1,16 +1,15 @@ version: 0.1 +android_test_host: amazon_linux_2 + phases: install: commands: - # Install correct version of node - - export NVM_DIR=$HOME/.nvm - export FLASHLIGHT_BINARY_PATH=$DEVICEFARM_TEST_PACKAGE_PATH/zip/bin - - . $NVM_DIR/nvm.sh # Note: Node v16 is the latest supported version of node for AWS Device Farm # using v20 will not work! - - nvm install 16 - - nvm use --delete-prefix 16 + - devicefarm-cli use node 16 + - node -v # Reverse ports using AWS magic - PORT=4723 diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 44ad43e69953..6e867fbb239b 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -198,7 +198,7 @@ async function signInAndGetApp(): Promise<void> { reportID: REPORT_ID, reportName: CONST.REPORT.DEFAULT_REPORT_NAME, lastMessageText: 'Test', - participants: {[USER_B_ACCOUNT_ID]: {hidden: false}}, + participants: {[USER_B_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, lastActorAccountID: USER_B_ACCOUNT_ID, type: CONST.REPORT.TYPE.CHAT, }); @@ -212,7 +212,7 @@ async function signInAndGetApp(): Promise<void> { reportID: COMMENT_LINKING_REPORT_ID, reportName: CONST.REPORT.DEFAULT_REPORT_NAME, lastMessageText: 'Test', - participants: {[USER_A_ACCOUNT_ID]: {hidden: false}}, + participants: {[USER_A_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, lastActorAccountID: USER_A_ACCOUNT_ID, type: CONST.REPORT.TYPE.CHAT, }); diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 1d31a707d81d..011f1e01668f 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -140,7 +140,7 @@ function signInAndGetAppWithUnreadChat(): Promise<void> { lastReadTime: reportAction3CreatedDate, lastVisibleActionCreated: reportAction9CreatedDate, lastMessageText: 'Test', - participants: {[USER_B_ACCOUNT_ID]: {hidden: false}}, + participants: {[USER_B_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, lastActorAccountID: USER_B_ACCOUNT_ID, type: CONST.REPORT.TYPE.CHAT, }); @@ -301,7 +301,7 @@ describe('Unread Indicators', () => { lastVisibleActionCreated: DateUtils.getDBTime(utcToZonedTime(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, 'UTC').valueOf()), lastMessageText: 'Comment 1', lastActorAccountID: USER_C_ACCOUNT_ID, - participants: {[USER_C_ACCOUNT_ID]: {hidden: false}}, + participants: {[USER_C_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, type: CONST.REPORT.TYPE.CHAT, }, }, diff --git a/tests/unit/EmojiTest.ts b/tests/unit/EmojiTest.ts index 6af24553bee9..c96228b49fbc 100644 --- a/tests/unit/EmojiTest.ts +++ b/tests/unit/EmojiTest.ts @@ -154,11 +154,6 @@ describe('EmojiTest', () => { it('correct suggests emojis accounting for keywords', () => { const thumbEmojisEn: Emoji[] = [ - { - name: 'hand_with_index_finger_and_thumb_crossed', - code: 'π«°', - types: ['π«°πΏ', 'π«°πΎ', 'π«°π½', 'π«°πΌ', 'π«°π»'], - }, { code: 'π', name: '+1', @@ -169,14 +164,14 @@ describe('EmojiTest', () => { name: '-1', types: ['ππΏ', 'ππΎ', 'ππ½', 'ππΌ', 'ππ»'], }, - ]; - - const thumbEmojisEs: Emoji[] = [ { - name: 'mano_con_dedos_cruzados', + name: 'hand_with_index_finger_and_thumb_crossed', code: 'π«°', types: ['π«°πΏ', 'π«°πΎ', 'π«°π½', 'π«°πΌ', 'π«°π»'], }, + ]; + + const thumbEmojisEs: Emoji[] = [ { code: 'π', name: '+1', @@ -187,6 +182,11 @@ describe('EmojiTest', () => { name: '-1', types: ['ππΏ', 'ππΎ', 'ππ½', 'ππΌ', 'ππ»'], }, + { + name: 'mano_con_dedos_cruzados', + code: 'π«°', + types: ['π«°πΏ', 'π«°πΎ', 'π«°π½', 'π«°πΌ', 'π«°π»'], + }, ]; expect(EmojiUtils.suggestEmojis(':thumb', 'en')).toEqual(thumbEmojisEn); @@ -194,11 +194,6 @@ describe('EmojiTest', () => { expect(EmojiUtils.suggestEmojis(':thumb', 'es')).toEqual(thumbEmojisEs); expect(EmojiUtils.suggestEmojis(':pulgar', 'es')).toEqual([ - { - name: 'mano_con_dedos_cruzados', - code: 'π«°', - types: ['π«°πΏ', 'π«°πΎ', 'π«°π½', 'π«°πΌ', 'π«°π»'], - }, { code: 'π€', name: 'mano_llΓ‘mame', @@ -214,6 +209,11 @@ describe('EmojiTest', () => { name: '-1', types: ['ππΏ', 'ππΎ', 'ππ½', 'ππΌ', 'ππ»'], }, + { + name: 'mano_con_dedos_cruzados', + code: 'π«°', + types: ['π«°πΏ', 'π«°πΎ', 'π«°π½', 'π«°πΌ', 'π«°π»'], + }, ]); }); }); diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index c2beebfa563d..6247dbba1f50 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -22,9 +22,9 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '1', participants: { - 2: {}, - 1: {}, - 5: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Iron Man, Mister Fantastic, Invisible Woman', type: CONST.REPORT.TYPE.CHAT, @@ -35,8 +35,8 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '2', participants: { - 2: {}, - 3: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Spider-Man', type: CONST.REPORT.TYPE.CHAT, @@ -49,8 +49,8 @@ describe('OptionsListUtils', () => { isPinned: true, reportID: '3', participants: { - 2: {}, - 1: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Mister Fantastic', type: CONST.REPORT.TYPE.CHAT, @@ -61,8 +61,8 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '4', participants: { - 2: {}, - 4: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Black Panther', type: CONST.REPORT.TYPE.CHAT, @@ -73,8 +73,8 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '5', participants: { - 2: {}, - 5: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Invisible Woman', type: CONST.REPORT.TYPE.CHAT, @@ -85,8 +85,8 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '6', participants: { - 2: {}, - 6: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Thor', type: CONST.REPORT.TYPE.CHAT, @@ -99,8 +99,8 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '7', participants: { - 2: {}, - 7: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Captain America', type: CONST.REPORT.TYPE.CHAT, @@ -113,8 +113,8 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '8', participants: { - 2: {}, - 12: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Silver Surfer', type: CONST.REPORT.TYPE.CHAT, @@ -127,8 +127,8 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '9', participants: { - 2: {}, - 8: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Mister Sinister', iouReportID: '100', @@ -142,8 +142,8 @@ describe('OptionsListUtils', () => { reportID: '10', isPinned: false, participants: { - 2: {}, - 7: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: '', oldPolicyName: "SHIELD's workspace", @@ -236,8 +236,8 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '11', participants: { - 2: {}, - 999: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Concierge', type: CONST.REPORT.TYPE.CHAT, @@ -252,8 +252,8 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '12', participants: { - 2: {}, - 1000: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Chronos', type: CONST.REPORT.TYPE.CHAT, @@ -268,8 +268,8 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '13', participants: { - 2: {}, - 1001: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Receipts', type: CONST.REPORT.TYPE.CHAT, @@ -284,10 +284,10 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '14', participants: { - 2: {}, - 1: {}, - 10: {}, - 3: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: '', oldPolicyName: 'Avengers Room', @@ -305,9 +305,9 @@ describe('OptionsListUtils', () => { isPinned: false, reportID: '15', participants: { - 2: {}, - 3: {}, - 4: {}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, reportName: 'Spider-Man, Black Panther', type: CONST.REPORT.TYPE.CHAT, diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 6161fa57f75c..2008cf45b746 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -884,7 +884,7 @@ describe('ReportUtils', () => { expect(ReportUtils.isChatUsedForOnboarding(LHNTestUtils.getFakeReport())).toBeFalsy(); }); - it('should return true if the user account ID is odd and report is the system chat', async () => { + it('should return false if the user account ID is odd and report is the system chat - only the Concierge chat chat should be the onboarding chat for users without the onboarding NVP', async () => { const accountID = 1; await Onyx.multiSet({ @@ -901,7 +901,7 @@ describe('ReportUtils', () => { chatType: CONST.REPORT.CHAT_TYPE.SYSTEM, }; - expect(ReportUtils.isChatUsedForOnboarding(report)).toBeTruthy(); + expect(ReportUtils.isChatUsedForOnboarding(report)).toBeFalsy(); }); it('should return true if the user account ID is even and report is the concierge chat', async () => { @@ -954,28 +954,44 @@ describe('ReportUtils', () => { const invoiceReport: Report = { reportID: '1', type: CONST.REPORT.TYPE.INVOICE, - participants: {[userAccountID]: {hidden: false}, [currentUserAccountID]: {hidden: false}}, + participants: { + [userAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, }; const taskReport: Report = { reportID: '2', type: CONST.REPORT.TYPE.TASK, - participants: {[userAccountID]: {hidden: false}, [currentUserAccountID]: {hidden: false}}, + participants: { + [userAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, }; const iouReport: Report = { reportID: '3', type: CONST.REPORT.TYPE.IOU, - participants: {[userAccountID]: {hidden: false}, [currentUserAccountID]: {hidden: false}}, + participants: { + [userAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, }; groupChatReport = { reportID: '4', type: CONST.REPORT.TYPE.CHAT, chatType: CONST.REPORT.CHAT_TYPE.GROUP, - participants: {[userAccountID]: {hidden: false}, [userAccountID2]: {hidden: false}, [currentUserAccountID]: {hidden: false}}, + participants: { + [userAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [userAccountID2]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, }; oneOnOneChatReport = { reportID: '5', type: CONST.REPORT.TYPE.CHAT, - participants: {[userAccountID]: {hidden: false}, [currentUserAccountID]: {hidden: false}}, + participants: { + [userAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, }; const reportCollectionDataSet = toCollectionDataSet( ONYXKEYS.COLLECTION.REPORT, diff --git a/tests/unit/SearchParserTest.ts b/tests/unit/SearchParserTest.ts index c8d3764f0546..1f2a97771bf3 100644 --- a/tests/unit/SearchParserTest.ts +++ b/tests/unit/SearchParserTest.ts @@ -193,17 +193,9 @@ const tests = [ right: '200', }, right: { - operator: 'or', - left: { - operator: 'eq', - left: 'expenseType', - right: 'cash', - }, - right: { - operator: 'eq', - left: 'expenseType', - right: 'card', - }, + operator: 'eq', + left: 'expenseType', + right: ['cash', 'card'], }, }, right: { @@ -219,25 +211,9 @@ const tests = [ }, }, right: { - operator: 'or', - left: { - operator: 'or', - left: { - operator: 'eq', - left: 'category', - right: 'travel', - }, - right: { - operator: 'eq', - left: 'category', - right: 'hotel', - }, - }, - right: { - operator: 'eq', - left: 'category', - right: 'meal & entertainment', - }, + operator: 'eq', + left: 'category', + right: ['travel', 'hotel', 'meal & entertainment'], }, }, }, @@ -252,23 +228,15 @@ const tests = [ filters: { operator: 'and', left: { - operator: 'or', - left: { - operator: 'eq', - left: 'keyword', - right: 'las', - }, - right: { - operator: 'eq', - left: 'keyword', - right: 'vegas', - }, - }, - right: { operator: 'gt', left: 'amount', right: '200', }, + right: { + operator: 'eq', + left: 'keyword', + right: ['las', 'vegas'], + }, }, }, }, @@ -282,19 +250,6 @@ const tests = [ filters: { operator: 'and', left: { - operator: 'or', - left: { - operator: 'eq', - left: 'keyword', - right: 'las', - }, - right: { - operator: 'eq', - left: 'keyword', - right: 'vegas', - }, - }, - right: { operator: 'and', left: { operator: 'gt', @@ -307,6 +262,11 @@ const tests = [ right: 'Hotel : Marriott', }, }, + right: { + operator: 'eq', + left: 'keyword', + right: ['las', 'vegas'], + }, }, }, }, @@ -320,19 +280,6 @@ const tests = [ filters: { operator: 'and', left: { - operator: 'or', - left: { - operator: 'eq', - left: 'keyword', - right: 'las', - }, - right: { - operator: 'eq', - left: 'keyword', - right: 'vegas', - }, - }, - right: { operator: 'and', left: { operator: 'and', @@ -351,55 +298,28 @@ const tests = [ right: 'Hotel : Marriott', }, }, - right: { - operator: 'or', - left: { - operator: 'eq', - left: 'date', - right: '2024-01-01', - }, - right: { - operator: 'eq', - left: 'date', - right: '2024-02-01', - }, - }, - }, - right: { - operator: 'or', - left: { - operator: 'eq', - left: 'merchant', - right: 'Expensify', - }, - right: { - operator: 'eq', - left: 'merchant', - right: 'Inc.', - }, - }, - }, - right: { - operator: 'or', - left: { - operator: 'or', - left: { - operator: 'eq', - left: 'tag', - right: 'hotel', - }, right: { operator: 'eq', - left: 'tag', - right: 'travel', + left: 'date', + right: ['2024-01-01', '2024-02-01'], }, }, right: { operator: 'eq', - left: 'tag', - right: 'meals & entertainment', + left: 'merchant', + right: 'Expensify, Inc.', }, }, + right: { + operator: 'eq', + left: 'tag', + right: ['hotel', 'travel', 'meals & entertainment'], + }, + }, + right: { + operator: 'eq', + left: 'keyword', + right: ['las', 'vegas'], }, }, }, diff --git a/tests/unit/UnreadIndicatorUpdaterTest.ts b/tests/unit/UnreadIndicatorUpdaterTest.ts index 9cf65bcb69d4..cf8119f3784a 100644 --- a/tests/unit/UnreadIndicatorUpdaterTest.ts +++ b/tests/unit/UnreadIndicatorUpdaterTest.ts @@ -1,6 +1,10 @@ /* eslint-disable @typescript-eslint/naming-convention */ import CONST from '../../src/CONST'; import * as UnreadIndicatorUpdater from '../../src/libs/UnreadIndicatorUpdater'; +import * as TestHelper from '../utils/TestHelper'; + +const TEST_USER_ACCOUNT_ID = 1; +const TEST_USER_LOGIN = 'test@test.com'; describe('UnreadIndicatorUpdaterTest', () => { describe('should return correct number of unread reports', () => { @@ -24,7 +28,9 @@ describe('UnreadIndicatorUpdaterTest', () => { }, 3: {reportID: '3', reportName: 'test', type: CONST.REPORT.TYPE.TASK, lastMessageText: 'test'}, }; - expect(UnreadIndicatorUpdater.getUnreadReportsForUnreadIndicator(reportsToBeUsed, '3').length).toBe(2); + TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID).then(() => { + expect(UnreadIndicatorUpdater.getUnreadReportsForUnreadIndicator(reportsToBeUsed, '3').length).toBe(2); + }); }); it('given some reports are incomplete', () => { @@ -42,7 +48,11 @@ describe('UnreadIndicatorUpdaterTest', () => { reportID: '1', reportName: 'test', type: CONST.REPORT.TYPE.EXPENSE, - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + participants: { + [TEST_USER_ACCOUNT_ID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + }, lastReadTime: '2023-07-08 07:15:44.030', lastVisibleActionCreated: '2023-08-08 07:15:44.030', lastMessageText: 'test', @@ -57,7 +67,9 @@ describe('UnreadIndicatorUpdaterTest', () => { }, 3: {reportID: '3', reportName: 'test', type: CONST.REPORT.TYPE.TASK, lastMessageText: 'test'}, }; - expect(UnreadIndicatorUpdater.getUnreadReportsForUnreadIndicator(reportsToBeUsed, '3').length).toBe(1); + TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID).then(() => { + expect(UnreadIndicatorUpdater.getUnreadReportsForUnreadIndicator(reportsToBeUsed, '3').length).toBe(1); + }); }); }); }); diff --git a/tests/unit/awaitStagingDeploysTest.ts b/tests/unit/awaitStagingDeploysTest.ts index 64d79c9faf94..26ce2778de40 100644 --- a/tests/unit/awaitStagingDeploysTest.ts +++ b/tests/unit/awaitStagingDeploysTest.ts @@ -33,8 +33,8 @@ type MockedFunctionListResponse = jest.MockedFunction<() => Promise<MockListResp const consoleSpy = jest.spyOn(console, 'log'); const mockGetInput = jest.fn(); -const mockListPlatformDeploysForTag: MockedFunctionListResponse = jest.fn(); -const mockListPlatformDeploys: MockedFunctionListResponse = jest.fn(); +const mockListDeploysForTag: MockedFunctionListResponse = jest.fn(); +const mockListDeploys: MockedFunctionListResponse = jest.fn(); const mockListPreDeploys: MockedFunctionListResponse = jest.fn(); const mockListWorkflowRuns = jest.fn().mockImplementation((args: Workflow) => { const defaultReturn = Promise.resolve({data: {workflow_runs: []}}); @@ -44,11 +44,11 @@ const mockListWorkflowRuns = jest.fn().mockImplementation((args: Workflow) => { } if (args.branch !== undefined) { - return mockListPlatformDeploysForTag(); + return mockListDeploysForTag(); } - if (args.workflow_id === 'platformDeploy.yml') { - return mockListPlatformDeploys(); + if (args.workflow_id === 'deploy.yml') { + return mockListDeploys(); } if (args.workflow_id === 'preDeploy.yml') { @@ -89,7 +89,7 @@ describe('awaitStagingDeploys', () => { mockGetInput.mockImplementation(() => undefined); // First ping - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [COMPLETED_WORKFLOW, INCOMPLETE_WORKFLOW, INCOMPLETE_WORKFLOW], }, @@ -101,7 +101,7 @@ describe('awaitStagingDeploys', () => { }); // Second ping - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [COMPLETED_WORKFLOW, COMPLETED_WORKFLOW, INCOMPLETE_WORKFLOW], }, @@ -113,7 +113,7 @@ describe('awaitStagingDeploys', () => { }); // Third ping - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [COMPLETED_WORKFLOW, COMPLETED_WORKFLOW, COMPLETED_WORKFLOW], }, @@ -125,7 +125,7 @@ describe('awaitStagingDeploys', () => { }); // Fourth ping - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [COMPLETED_WORKFLOW, COMPLETED_WORKFLOW, COMPLETED_WORKFLOW], }, @@ -149,12 +149,12 @@ describe('awaitStagingDeploys', () => { mockGetInput.mockImplementation(() => 'my-tag'); // First ping - mockListPlatformDeploysForTag.mockResolvedValueOnce({ + mockListDeploysForTag.mockResolvedValueOnce({ data: { workflow_runs: [INCOMPLETE_WORKFLOW], }, }); - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [INCOMPLETE_WORKFLOW, INCOMPLETE_WORKFLOW], }, @@ -166,12 +166,12 @@ describe('awaitStagingDeploys', () => { }); // Second ping - mockListPlatformDeploysForTag.mockResolvedValueOnce({ + mockListDeploysForTag.mockResolvedValueOnce({ data: { workflow_runs: [INCOMPLETE_WORKFLOW], }, }); - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [INCOMPLETE_WORKFLOW, COMPLETED_WORKFLOW], }, @@ -183,12 +183,12 @@ describe('awaitStagingDeploys', () => { }); // Third ping - mockListPlatformDeploysForTag.mockResolvedValueOnce({ + mockListDeploysForTag.mockResolvedValueOnce({ data: { workflow_runs: [COMPLETED_WORKFLOW], }, }); - mockListPlatformDeploys.mockResolvedValueOnce({ + mockListDeploys.mockResolvedValueOnce({ data: { workflow_runs: [INCOMPLETE_WORKFLOW, COMPLETED_WORKFLOW, INCOMPLETE_WORKFLOW], },