diff --git a/.github/actions/composite/buildAndroidAPK/action.yml b/.github/actions/composite/buildAndroidAPK/action.yml
index 819234df0bc3..fc280ab2a223 100644
--- a/.github/actions/composite/buildAndroidAPK/action.yml
+++ b/.github/actions/composite/buildAndroidAPK/action.yml
@@ -13,7 +13,7 @@ runs:
- uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7
with:
- ruby-version: '2.7'
+ ruby-version: "2.7"
bundler-cache: true
- uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef
@@ -26,4 +26,4 @@ runs:
uses: actions/upload-artifact@65d862660abb392b8c4a3d1195a2108db131dd05
with:
name: ${{ inputs.ARTIFACT_NAME }}
- path: android/app/build/outputs/apk/e2eRelease/app-e2eRelease.apk
+ path: android/app/build/outputs/apk/e2e/release/app-e2e-release.apk
diff --git a/.github/actions/composite/setupGitForOSBotify/action.yml b/.github/actions/composite/setupGitForOSBotify/action.yml
index 0c06e2f4e169..bacf45cf1ed1 100644
--- a/.github/actions/composite/setupGitForOSBotify/action.yml
+++ b/.github/actions/composite/setupGitForOSBotify/action.yml
@@ -1,11 +1,23 @@
-name: 'Setup Git for OSBotify'
-description: 'Setup Git for OSBotify'
+name: "Setup Git for OSBotify"
+description: "Setup Git for OSBotify"
inputs:
GPG_PASSPHRASE:
- description: 'Passphrase used to decrypt GPG key'
+ description: "Passphrase used to decrypt GPG key"
+ required: true
+ OS_BOTIFY_APP_ID:
+ description: "Application ID for OS Botify"
+ required: true
+ OS_BOTIFY_PRIVATE_KEY:
+ description: "OS Botify's private key"
required: true
+outputs:
+ # Do not try to use this for committing code. Use `secrets.OS_BOTIFY_COMMIT_TOKEN` instead
+ OS_BOTIFY_API_TOKEN:
+ description: Token to use for GitHub API interactions.
+ value: ${{ steps.generateToken.outputs.token }}
+
runs:
using: composite
steps:
@@ -29,3 +41,10 @@ runs:
shell: bash
if: runner.debug == '1'
run: echo "GIT_TRACE=true" >> "$GITHUB_ENV"
+
+ - name: Generate a token
+ id: generateToken
+ uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
+ with:
+ app_id: ${{ inputs.OS_BOTIFY_APP_ID }}
+ private_key: ${{ inputs.OS_BOTIFY_PRIVATE_KEY }}
diff --git a/.github/actions/javascript/getPullRequestDetails/action.yml b/.github/actions/javascript/getPullRequestDetails/action.yml
index a59cf55bdf9f..ed2c60f018a1 100644
--- a/.github/actions/javascript/getPullRequestDetails/action.yml
+++ b/.github/actions/javascript/getPullRequestDetails/action.yml
@@ -13,8 +13,14 @@ inputs:
outputs:
MERGE_COMMIT_SHA:
description: 'The merge_commit_sha of the given pull request'
+ HEAD_COMMIT_SHA:
+ description: 'The head_commit_sha of the given pull request'
MERGE_ACTOR:
description: 'The actor who merged the pull request'
+ IS_MERGED:
+ description: 'True if the pull request is merged'
+ FORKED_REPO_URL:
+ description: 'Output forked repo URL if PR includes changes from a fork'
runs:
using: 'node16'
main: './index.js'
diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml
index b6558b049647..995a8f36ab5a 100644
--- a/.github/workflows/cherryPick.yml
+++ b/.github/workflows/cherryPick.yml
@@ -44,12 +44,14 @@ jobs:
uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+ OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
+ OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}
- name: Get previous app version
id: getPreviousVersion
uses: Expensify/App/.github/actions/javascript/getPreviousVersion@main
with:
- SEMVER_LEVEL: 'PATCH'
+ SEMVER_LEVEL: "PATCH"
- name: Fetch history of relevant refs
run: |
@@ -119,7 +121,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
- - name: 'Announces a CP failure in the #announce Slack room'
+ - name: "Announces a CP failure in the #announce Slack room"
uses: 8398a7/action-slack@v3
if: ${{ failure() }}
with:
diff --git a/.github/workflows/createNewVersion.yml b/.github/workflows/createNewVersion.yml
index ba907334c595..a693095aaffa 100644
--- a/.github/workflows/createNewVersion.yml
+++ b/.github/workflows/createNewVersion.yml
@@ -26,12 +26,18 @@ on:
LARGE_SECRET_PASSPHRASE:
description: Passphrase used to decrypt GPG key
required: true
- OS_BOTIFY_TOKEN:
- description: Token for the OSBotify user
- required: true
SLACK_WEBHOOK:
description: Webhook used to comment in slack
required: true
+ OS_BOTIFY_COMMIT_TOKEN:
+ description: OSBotify personal access token, used to workaround committing to protected branch
+ required: true
+ OS_BOTIFY_APP_ID:
+ description: Application ID for OS Botify App
+ required: true
+ OS_BOTIFY_PRIVATE_KEY:
+ description: OSBotify private key
+ required: true
jobs:
validateActor:
@@ -43,7 +49,7 @@ jobs:
id: getUserPermissions
run: echo "PERMISSION=$(gh api /repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission | jq -r '.permission')" >> "$GITHUB_OUTPUT"
env:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }}
createNewVersion:
runs-on: macos-latest
@@ -65,18 +71,23 @@ jobs:
uses: actions/checkout@v3
with:
ref: main
- token: ${{ secrets.OS_BOTIFY_TOKEN }}
+ # The OS_BOTIFY_COMMIT_TOKEN is a personal access token tied to osbotify
+ # This is a workaround to allow pushes to a protected branch
+ token: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }}
- name: Setup git for OSBotify
uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main
+ 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: Generate version
id: bumpVersion
uses: Expensify/App/.github/actions/javascript/bumpVersion@main
with:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
SEMVER_LEVEL: ${{ inputs.SEMVER_LEVEL }}
- name: Commit new version
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f2ff67680940..c42f3bee617a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -9,16 +9,18 @@ jobs:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/staging'
steps:
+ - uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main
+ id: setupGitForOSBotify
+ with:
+ GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+ OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
+ OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}
+
- name: Checkout staging branch
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8
with:
ref: staging
- token: ${{ secrets.OS_BOTIFY_TOKEN }}
-
- - name: Setup git for OSBotify
- uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main
- with:
- GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+ token: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
- name: Tag version
run: git tag "$(npm run print-version --silent)"
@@ -30,11 +32,19 @@ jobs:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/production'
steps:
+ - uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main
+ 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 }}
+
+ - uses: actions/checkout@v3
- name: Checkout
uses: actions/checkout@v3
with:
ref: production
- token: ${{ secrets.OS_BOTIFY_TOKEN }}
+ token: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
- name: Setup git for OSBotify
uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main
@@ -49,7 +59,7 @@ jobs:
uses: Expensify/App/.github/actions/javascript/getDeployPullRequestList@main
with:
TAG: ${{ env.PRODUCTION_VERSION }}
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
IS_PRODUCTION_DEPLOY: true
- name: Generate Release Body
@@ -64,4 +74,4 @@ jobs:
tag_name: ${{ env.PRODUCTION_VERSION }}
body: ${{ steps.getReleaseBody.outputs.RELEASE_BODY }}
env:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml
index d8f9cad138d9..f7f1e5fc7ac7 100644
--- a/.github/workflows/e2ePerformanceTests.yml
+++ b/.github/workflows/e2ePerformanceTests.yml
@@ -84,12 +84,7 @@ jobs:
- name: Unmerged PR - Fetch head ref of unmerged PR
if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }}
run: |
- if [[ ${{ steps.getPullRequestDetails.outputs.FORKED_REPO_URL }} != '' ]]; then
- git remote add pr_remote ${{ steps.getPullRequestDetails.outputs.FORKED_REPO_URL }}
- git fetch pr_remote ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} --no-tags --depth=1
- else
- git fetch origin ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} --no-tags --depth=1
- fi
+ git fetch origin ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} --no-tags --depth=1
- name: Unmerged PR - Set dummy git credentials before merging
if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }}
@@ -101,7 +96,7 @@ jobs:
if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }}
id: getMergeCommitShaIfUnmergedPR
run: |
- git merge --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }}
+ git merge --allow-unrelated-histories --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }}
git checkout ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }}
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -140,18 +135,19 @@ jobs:
name: baseline-apk-${{ needs.buildBaseline.outputs.VERSION }}
path: zip
- # The downloaded artifact will be a file named "app-e2eRelease.apk" so we have to rename it
+ # The downloaded artifact will be a file named "app-e2e-release.apk" so we have to rename it
- name: Rename baseline APK
- run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-baseline.apk"
+ run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-baseline.apk"
- name: Download delta APK
uses: actions/download-artifact@e9ef242655d12993efdcda9058dee2db83a2cb9b
+ id: downloadDeltaAPK
with:
name: delta-apk-${{ needs.buildDelta.outputs.DELTA_REF }}
path: zip
- name: Rename delta APK
- run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-compare.apk"
+ run: mv "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2eRelease-compare.apk"
- name: Copy e2e code into zip folder
run: cp -r tests/e2e zip
diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml
index e2323af2486e..1ea940f5535c 100644
--- a/.github/workflows/finishReleaseCycle.yml
+++ b/.github/workflows/finishReleaseCycle.yml
@@ -12,6 +12,13 @@ jobs:
outputs:
isValid: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && !fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS) }}
steps:
+ - uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main
+ 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: Validate actor is deployer
id: isDeployer
run: |
@@ -21,13 +28,13 @@ jobs:
echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT"
fi
env:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
- name: Reopen and comment on issue (not a team member)
if: ${{ !fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) }}
uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main
with:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENT: |
Sorry, only members of @Expensify/Mobile-Deployers can close deploy checklists.
@@ -38,14 +45,14 @@ jobs:
id: checkDeployBlockers
uses: Expensify/App/.github/actions/javascript/checkDeployBlockers@main
with:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
- name: Reopen and comment on issue (has blockers)
if: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS || 'false') }}
uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main
with:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENT: |
This issue either has unchecked items or has not yet been marked with the `:shipit:` emoji of approval.
@@ -70,9 +77,12 @@ jobs:
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- name: Setup Git for OSBotify
+ id: setupGitForOSBotify
uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main
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: Update production branch
run: |
@@ -112,6 +122,8 @@ jobs:
uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main
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: Update staging branch to trigger staging deploy
run: |
diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml
index 186490c7baaf..86fee0fd3de0 100644
--- a/.github/workflows/preDeploy.yml
+++ b/.github/workflows/preDeploy.yml
@@ -95,6 +95,8 @@ jobs:
uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main
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: Update staging branch from main
run: |
diff --git a/.storybook/preview.js b/.storybook/preview.js
index b198c0d2d626..a989960794f2 100644
--- a/.storybook/preview.js
+++ b/.storybook/preview.js
@@ -6,7 +6,7 @@ import './fonts.css';
import ComposeProviders from '../src/components/ComposeProviders';
import HTMLEngineProvider from '../src/components/HTMLEngineProvider';
import OnyxProvider from '../src/components/OnyxProvider';
-import {LocaleContextProvider} from '../src/components/withLocalize';
+import {LocaleContextProvider} from '../src/components/LocaleContextProvider';
import {KeyboardStateProvider} from '../src/components/withKeyboardState';
import {EnvironmentProvider} from '../src/components/withEnvironment';
import {WindowDimensionsProvider} from '../src/components/withWindowDimensions';
diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association
index a9e2b0383691..d6da0232f2fc 100644
--- a/.well-known/apple-app-site-association
+++ b/.well-known/apple-app-site-association
@@ -75,6 +75,10 @@
{
"/": "/teachersunite/*",
"comment": "Teachers Unite!"
+ },
+ {
+ "/": "/search/*",
+ "comment": "Search"
}
]
}
diff --git a/android/app/build.gradle b/android/app/build.gradle
index afe24fc37700..d85dda721838 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -58,7 +58,7 @@ project.ext.envConfigFiles = [
adhocRelease: ".env.adhoc",
developmentRelease: ".env",
developmentDebug: ".env",
- e2eRelease: ".env.production"
+ e2eRelease: "tests/e2e/.env.e2e"
]
/**
@@ -90,8 +90,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001037403
- versionName "1.3.74-3"
+ versionCode 1001037508
+ versionName "1.3.75-8"
}
flavorDimensions "default"
@@ -136,10 +136,20 @@ android {
signingConfig signingConfigs.debug
}
release {
- signingConfig signingConfigs.release
productFlavors.production.signingConfig signingConfigs.release
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
+
+ signingConfig null
+ // buildTypes take precedence over productFlavors when it comes to the signing configuration,
+ // thus we need to manually set the signing config, so that the e2e uses the debug config again.
+ // In other words, the signingConfig setting above will be ignored when we build the flavor in release mode.
+ productFlavors.all { flavor ->
+ // All release builds should be signed with the release config ...
+ flavor.signingConfig signingConfigs.release
+ }
+ // ... except for the e2e flavor, which we maybe want to build locally:
+ productFlavors.e2e.signingConfig signingConfigs.debug
}
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8d69c62bfd1f..dc135fa9834e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -67,6 +67,7 @@
+
@@ -83,6 +84,7 @@
+
diff --git a/assets/images/chatbubbles.svg b/assets/images/chatbubbles.svg
new file mode 100644
index 000000000000..6194c43e631e
--- /dev/null
+++ b/assets/images/chatbubbles.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/assets/images/google-meet.svg b/assets/images/google-meet.svg
index 138a11859321..980cd102f67a 100644
--- a/assets/images/google-meet.svg
+++ b/assets/images/google-meet.svg
@@ -1,8 +1,7 @@
-
\ No newline at end of file
+
+
+
diff --git a/assets/images/zoom-icon.svg b/assets/images/zoom-icon.svg
index 6c6ed03cb2f3..24d019654795 100644
--- a/assets/images/zoom-icon.svg
+++ b/assets/images/zoom-icon.svg
@@ -1 +1,7 @@
-
\ No newline at end of file
+
+
+
diff --git a/docs/Gemfile b/docs/Gemfile
index 7cad729ee45b..701ae50ca381 100644
--- a/docs/Gemfile
+++ b/docs/Gemfile
@@ -32,3 +32,6 @@ gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby]
gem "webrick", "~> 1.7"
gem 'jekyll-seo-tag'
+
+gem 'jekyll-redirect-from'
+
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
index 1a5b26e2dc23..0963d3c73e6c 100644
--- a/docs/Gemfile.lock
+++ b/docs/Gemfile.lock
@@ -263,6 +263,7 @@ DEPENDENCIES
github-pages
http_parser.rb (~> 0.6.0)
jekyll-feed (~> 0.12)
+ jekyll-redirect-from
jekyll-seo-tag
tzinfo (~> 1.2)
tzinfo-data
@@ -270,4 +271,4 @@ DEPENDENCIES
webrick (~> 1.7)
BUNDLED WITH
- 2.4.3
+ 2.4.19
diff --git a/docs/_config.yml b/docs/_config.yml
index 114e562cae04..4a0ce8c053c5 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -17,3 +17,7 @@ exclude: [README.md, TEMPLATE.md, vendor]
plugins:
- jekyll-seo-tag
+ - jekyll-redirect-from
+
+whitelist:
+ - jekyll-redirect-from
diff --git a/docs/articles/expensify-classic/account-settings/Account-Access.md b/docs/articles/expensify-classic/account-settings/Account-Access.md
deleted file mode 100644
index b3126201715f..000000000000
--- a/docs/articles/expensify-classic/account-settings/Account-Access.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Account Access
-description: Account Access
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/account-settings/Account-Details.md b/docs/articles/expensify-classic/account-settings/Account-Details.md
new file mode 100644
index 000000000000..46a6c6ba0c25
--- /dev/null
+++ b/docs/articles/expensify-classic/account-settings/Account-Details.md
@@ -0,0 +1,5 @@
+---
+title: Account Details
+description: Account Details
+---
+## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/exports/Insights.md b/docs/articles/expensify-classic/exports/Insights.md
index 682c2a251228..6c71630015c5 100644
--- a/docs/articles/expensify-classic/exports/Insights.md
+++ b/docs/articles/expensify-classic/exports/Insights.md
@@ -1,6 +1,7 @@
---
title: Custom Reporting and Insights
description: How to get the most out of the Custom Reporing and Insights
+redirect_from: articles/other/Insights/
---
{% raw %}
diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md b/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md
index 36e0a2194d24..b5f5ec8be048 100644
--- a/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md
+++ b/docs/articles/expensify-classic/get-paid-back/expenses/Apply-Tax.md
@@ -1,5 +1,39 @@
---
title: Apply Tax
-description: Apply Tax
+description: This is article shows you how to apply taxes to your expenses!
---
-## Resource Coming Soon!
+
+
+
+# About
+
+There are two types of tax in Expensify: Simple Tax (i.e. one tax rate) and Complex Tax (i.e. more than one tax rate). This article shows you how to apply both to your expenses!
+
+
+# How-to Apply Tax
+
+When Tax Tracking is enabled on a Workspace, the default tax rate is selected under **Settings > Workspace > _Workspace Name_ > Tax**, with the default tax rate applied to all expenses automatically.
+
+There may be multiple tax rates set up within your Workspace, so if the tax on your receipt is different to the default tax that has been applied, you can select the appropriate rate from the tax drop-down on the web expense editor or the mobile app.
+
+If the tax amount on your receipt is different to the calculated amount or the tax rate doesn’t show up, you can always manually type in the correct tax amount.
+
+
+# FAQ
+
+## How do I set up multiple taxes (GST/PST/QST) on indirect connections?
+Expenses sometimes have more than one tax applied to them - for example in Canada, expenses can have both a Federal GST and a provincial PST or QST.
+
+To handle these, you can create a single tax that combines both taxes into a single effective tax rate. For example, if you have a GST of 5% and PST of 7%, adding the two tax rates together gives you an effective tax rate of 12%.
+
+From the Reports page, you can select Reports and then click **Export To > Tax Report** to generate a CSV containing all the expense information, including the split-out taxes.
+
+
+# Deep Dive
+
+If you have a receipt that has more than one tax rate (i.e. Complex Tax) on it, then there are two options for handling this in Expensify!
+
+Many tax authorities do not require the reporting of tax amounts by rate and the easiest approach is to apply the highest rate on the receipt and then modify the tax amount to reflect the amount shown on the receipt if this is less. Please check with your local tax advisor if this approach will be allowed.
+
+Alternatively, you can apply each specific tax rate by splitting the expense into the components that each rate will be applied to. To do this, click on **Split Expense** and apply the correct tax rate to each part.
+
diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md
index e565e59dc754..7fa714189542 100644
--- a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md
+++ b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md
@@ -48,11 +48,11 @@ You can also create a number of future 'placeholder' expenses for your recurring
# How to Edit Bulk Expenses
Editing expenses in bulk will allow you to apply the same coding across multiple expenses and is a web-only feature. To bulk edit expenses:
Go to the Expenses page.
-To narrow down your selection, use the filters (e.g. "Merchant" and "Open") to find the specific expenses you want to edit.
+To narrow down your selection, use the filters (e.g. "Merchant" and "Draft") to find the specific expenses you want to edit.
Select all the expenses you want to edit.
Click on the **Edit Multiple** button at the top of the page.
# How to Edit Expenses on a Report
-If you’d like to edit expenses within an Open report:
+If you’d like to edit expenses within a Draft report:
1. Click on the Report containing all the expenses.
2. Click on **Details**.
@@ -61,8 +61,8 @@ If you’d like to edit expenses within an Open report:
If you've already submitted your report, you'll need to Retract it or have it Unapproved first before you can edit the expenses.
-
# FAQ
+
## Does Expensify account for duplicates?
Yes, Expensify will account for duplicates. Expensify works behind the scenes to identify duplicate expenses before they are submitted, warning employees when they exist. If a duplicate expense is submitted, the same warning will be shown to the approver responsible for reviewing the report.
@@ -71,6 +71,7 @@ If two expenses are SmartScanned on the same day for the same amount, they will
The expenses were split from a single expense,
The expenses were imported from a credit card, or
Matching email receipts sent to receipts@expensify.com were received with different timestamps.
+
## How do I resolve a duplicate expense?
If Concierge has let you know it's flagged a receipt as a duplicate, scanning the receipt again will trigger the same duplicate flagging.Users have the ability to resolve duplicates by either deleting the duplicated transactions, merging them, or ignoring them (if they are legitimately separate expenses of the same date and amount).
@@ -88,12 +89,13 @@ Click the **Undelete** button and you're all set. You’ll find the expense on y
## What are the different Expense statuses?
There are a number of different expense statuses in Expensify:
-1. **Unreported**: Unreported expenses are not yet part of a report (and therefore unsubmitted) and are not viewable by anyone but the expense creator/owner.
-2. **Open**: Open expenses are on a report that's still in progress, and are unsubmitted. Your Policy Admin will be able to view them, making it a collaborative step toward reimbursement.
+1. **Personal**: Personal expenses are not yet part of a report (and therefore unsubmitted) and are not viewable by anyone but the expense creator/owner.
+2. **Draft**: Draft expenses are seen as still in progress, and are unsubmitted. Your Policy Admin will be able to view them, making this a collaborative step toward reimbursement.
3. **Processing**: Processing expenses are submitted, but waiting for approval.
4. **Approved**: If it's a non-reimbursable expense, the workflow is complete at this point. If it's a reimbursable expense, you're one step closer to getting paid.
5. **Reimbursed**: Reimbursed expenses are fully settled. You can check the Report Comments to see when you'll get paid.
6. **Closed**: Sometimes an expense accidentally ends up on your Individual Policy, falling into the Closed status. You’ll need to reopen the report and change the Policy by clicking on the **Details** tab in order to resubmit your report.
+
## What are Violations?
Violations represent errors or discrepancies that Expensify has picked up and need to be corrected before a report can be successfully submitted. The one exception is when an expense comment is added, it will override the violation - as the user is providing a valid reason for submission.
@@ -101,8 +103,9 @@ Violations represent errors or discrepancies that Expensify has picked up and ne
To enable or configure violations according to your policy, go to **Settings > Policies > _Policy Name_ > Expenses > Expense Violations**. Keep in mind that Expensify includes certain system mandatory violations that can't be disabled, even if your policy has violations turned off.
You can spot violations by the exclamation marks (!) attached to expenses. Hovering over the symbol will provide a brief description and you can find more detailed information below the list of expenses. The two types of violations are:
-**Red**: These indicate violations directly tied to your report's Policy settings. They are clear rule violations that must be addressed before submission.
-**Yellow**: Concierge will highlight items that require attention but may not necessarily need corrective action. For example, if a receipt was SmartScanned and then the amount was modified, we’ll bring it to your attention so that it can be manually reviewed.
+1. **Red**: These indicate violations directly tied to your report's Policy settings. They are clear rule violations that must be addressed before submission.
+2. **Yellow**: Concierge will highlight items that require attention but may not necessarily need corrective action. For example, if a receipt was SmartScanned and then the amount was modified, we’ll bring it to your attention so that it can be manually reviewed.
+
## How to Track Attendees
Attendee tracking makes it easy to track shared expenses and maintain transparency in your group spending.
@@ -116,9 +119,10 @@ External attendees are considered users outside your group policy or domain. To
1. Click or tap the **Attendee** field within your expense.
2. Type in the individual's name or email address.
3. Tap **Add** to include the attendee.
-You can continue adding more attendees or save the Expense.
+4. You can continue adding more attendees or save the Expense.
+
To remove an attendee from an expense:
-Open the expense.
-Click or tap the **Attendees** field to display the list of attendees.
-From the list, de-select the attendees you'd like to remove from the expense.
+1. Open the expense.
+2. Click or tap the **Attendees** field to display the list of attendees.
+3. From the list, de-select the attendees you'd like to remove from the expense.
diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md b/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md
index bfbc0773768c..a8444b98c951 100644
--- a/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md
+++ b/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md
@@ -14,7 +14,7 @@ Keep in mind:
1. Merging expenses cannot be undone.
2. You can only merge two expenses at a time.
3. You can merge a cash expense with a credit card expense, or two cash expenses - but not two credit card expenses.
-4. In order to merge, both expenses will need to be in an Open or Unreported state.
+4. In order to merge, both expenses will need to be in a Personal or Draft status.
# How to merge expenses on the web app
To merge two expenses from the Expenses page:
@@ -41,11 +41,12 @@ If the expenses exist on two different reports, you will be asked which report y
## Can you merge expenses across different reports?
-You cannot merge expenses across different reports. Expenses will only merge if they are on the same report. If you have expenses across different reports that you wish to merge, you’ll need to move both expenses onto the same report (and ensure they are in the Open status) in order to merge them.
+You cannot merge expenses across different reports. Expenses will only merge if they are on the same report. If you have expenses across different reports that you wish to merge, you’ll need to move both expenses onto the same report (and ensure they are in the Draft status) in order to merge them.
## Can you merge expenses across different accounts?
You cannot merge expenses across two separate accounts. You will need to choose one submitter and transfer the expense information to that user's account in order to merge the expense.
+
## Can you merge expenses with different currencies?
Yes, you can merge expenses with different currencies. The conversion amount will be based on the daily exchange rate for the date of the transaction, as long as the converted rates are within +/- 5%. If the currencies are the same, then the amounts must be an exact match to merge.
diff --git a/docs/articles/expensify-classic/getting-started/Individual-Users.md b/docs/articles/expensify-classic/getting-started/Individual-Users.md
index de7a527df010..12029f80388b 100644
--- a/docs/articles/expensify-classic/getting-started/Individual-Users.md
+++ b/docs/articles/expensify-classic/getting-started/Individual-Users.md
@@ -1,5 +1,43 @@
---
title: Individual Users
-description: Individual Users
+description: Learn how Expensify can help you track and submit your personal or self-employed business expenses.
---
-## Resource Coming Soon!
+# Overview
+If you're an individual using Expensify, the Track and Submit plans are designed to assist self-employed users in effectively managing both their personal and business finances.
+
+# How to use the Track plan
+
+The Track plan is tailored for solo Expensify users who don't require expense submission to others. Individuals or sole proprietors can choose the Track plan to customize their Individual Workspace to align with their personal expense tracking requirements.
+
+You can select the Track plan from the Workspace settings. Navigate to **Settings > Workspace > Individual > *[Workspace Name]* > Plan** to select Track.
+You can also do this from the Pricing page at https://www.expensify.com/pricing.
+
+The Track plan includes a predefined set of categories designed to align with IRS Schedule C expense categories. However, you have the flexibility to add extra categories as needed. For a more detailed breakdown, you can also set up tags to create another layer of coding.
+
+The Track plan offers 25 free SmartScans per month. If you require more than 25 SmartScans, you can upgrade to a Monthly Individual subscription at a cost of $4.99 USD per month.
+
+# How to use the Submit plan
+The Submit plan is designed for individuals who need to keep track of their expenses and share them with someone else, such as their boss, accountant, or even a housemate. It's specifically tailored for single users who want to both track and submit their expenses efficiently.
+
+You can select the Track plan from the Workspace settings. Navigate to **Settings > Workspaces > Individual > *[Workspace Name]* > Plan** to select "Submit" or from the Pricing page at https://www.expensify.com/pricing.
+
+You will select who your expenses get sent to under **Settings > Workspace > Individual > *[Workspace Name]* > Reports**. If the recipient already has an Expensify account, they'll be able to see the report directly in the Expensify app. Otherwise, non-Expensify users will receive a PDF copy of the report attached to the email so it can be processed.
+
+The Submit plan includes a predefined set of categories designed to align with IRS Schedule C expense categories. However, you have the flexibility to add extra categories as needed. For a more detailed breakdown, you can also set up tags to create another layer of coding.
+
+The Submit plan offers 25 free SmartScans per month.If you require more than 25 SmartScans, you can upgrade to a Monthly Individual subscription at a cost of $4.99 USD per month.
+
+# FAQ
+
+## Who should use the Track plan?
+An individual who wants to store receipts, look to track spending by category to help with budgeting and a self-employed user who needs to track receipts and mileage for tax purposes.
+
+## Who should use the Submit plan?
+An individual who seeks to utilize the features of the track plan to monitor their expenses while also requiring the ability to submit those expenses to someone else.
+
+## How can I keep track of personal and business expenses in the same account?
+You have the capability to create distinct "business" and "personal" tags and assign them to your expenses for proper categorization. By doing so, you can effectively code your expenses based on their nature. Additionally, you can utilize filters to ensure that you only view the expenses that are relevant to your specific needs, whether they are business-related or personal.
+
+## How can I export expenses for tax purposes?
+From the expense page, you have the option to select all of your expenses and export them to a CSV (Comma-Separated Values) file. This CSV file can be conveniently imported directly into your tax software for easier tax preparation.
+
diff --git a/docs/articles/expensify-classic/getting-started/Referral-Program.md b/docs/articles/expensify-classic/getting-started/Referral-Program.md
index 683e93d0277a..b4a2b4a7de74 100644
--- a/docs/articles/expensify-classic/getting-started/Referral-Program.md
+++ b/docs/articles/expensify-classic/getting-started/Referral-Program.md
@@ -1,6 +1,7 @@
---
title: Expensify Referral Program
description: Send your joining link, submit a receipt or invoice, and we'll pay you if your referral adopts Expensify.
+redirect_from: articles/other/Referral-Program/
---
diff --git a/docs/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners.md b/docs/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners.md
index b18531d43200..a8e1b0690b72 100644
--- a/docs/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners.md
+++ b/docs/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners.md
@@ -1,6 +1,7 @@
---
title: Expensify Card revenue share for ExpensifyApproved! partners
description: Earn money when your clients adopt the Expensify Card
+redirect_from: articles/other/Card-Revenue-Share-for-ExpensifyApproved!-Partners/
---
diff --git a/docs/articles/expensify-classic/getting-started/approved-accountants/Your-Expensify-Partner-Manager.md b/docs/articles/expensify-classic/getting-started/approved-accountants/Your-Expensify-Partner-Manager.md
index c7a5dc5a04ab..104cd49daf96 100644
--- a/docs/articles/expensify-classic/getting-started/approved-accountants/Your-Expensify-Partner-Manager.md
+++ b/docs/articles/expensify-classic/getting-started/approved-accountants/Your-Expensify-Partner-Manager.md
@@ -1,6 +1,7 @@
---
title: Your Expensify Partner Manager
description: Everything you need to know about your Expensify Partner Manager
+redirect_from: articles/other/Your-Expensify-Partner-Manager/
---
diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
index 2b95a1d13fde..a7553e6ae179 100644
--- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
+++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
@@ -1,6 +1,7 @@
---
title: Expensify Playbook for Small to Medium-Sized Businesses
description: Best practices for how to deploy Expensify for your business
+redirect_from: articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses/
---
## Overview
This guide provides practical tips and recommendations for small businesses with 100 to 250 employees to effectively use Expensify to improve spend visibility, facilitate employee reimbursements, and reduce the risk of fraudulent expenses.
diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md
index 86c6a583c758..bef59546a13d 100644
--- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md
+++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md
@@ -1,6 +1,7 @@
---
title: Expensify Playbook for US-Based Bootstrapped Startups
description: Best practices for how to deploy Expensify for your business
+redirect_from: articles/playbooks/Expensify-Playbook-for-US-Based-Bootstrapped-Startups/
---
This playbook details best practices on how Bootstrapped Startups with less than 5 employees can use Expensify to prioritize product development while capturing business-related receipts for future reimbursement.
diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md
index 501d2f1538ef..bdce2cd7bf81 100644
--- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md
+++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md
@@ -1,6 +1,7 @@
---
title: Expensify Playbook for US-Based VC-Backed Startups
description: Best practices for how to deploy Expensify for your business
+redirect_from: articles/playbooks/Expensify-Playbook-for-US-based-VC-Backed-Startups/
---
This playbook details best practices on how Seed to Series A startups with under 100 employees can use Expensify to prioritize top-line revenue growth while managing spend responsibly.
diff --git a/docs/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager.md b/docs/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager.md
index 3ef47337a74c..a6fa0220c0dc 100644
--- a/docs/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager.md
+++ b/docs/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager.md
@@ -1,6 +1,7 @@
---
title: Your Expensify Account Manager
description: Everything you need to know about Having an Expensify account manager
+redirect_from: articles/other/Your-Expensify-Account-Manager/
---
diff --git a/docs/articles/expensify-classic/getting-started/tips-and-tricks/Enable-Location-Access-On-Web.md b/docs/articles/expensify-classic/getting-started/tips-and-tricks/Enable-Location-Access-On-Web.md
index 649212b00f7b..507d24503af8 100644
--- a/docs/articles/expensify-classic/getting-started/tips-and-tricks/Enable-Location-Access-On-Web.md
+++ b/docs/articles/expensify-classic/getting-started/tips-and-tricks/Enable-Location-Access-On-Web.md
@@ -1,6 +1,7 @@
---
title: Enable Location Access on Web
description: How to enable location access for Expensify websites on your browser
+redirect_from: articles/other/Enable-Location-Access-on-Web/
---
diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Greenhouse.md b/docs/articles/expensify-classic/integrations/HR-integrations/Greenhouse.md
index 3ee1c8656b4b..b44e5a090d17 100644
--- a/docs/articles/expensify-classic/integrations/HR-integrations/Greenhouse.md
+++ b/docs/articles/expensify-classic/integrations/HR-integrations/Greenhouse.md
@@ -1,5 +1,43 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Greenhouse Integration
+description: Automatically send candidates from Greenhouse to Expensify for easy reimbursement
---
-## Resource Coming Soon!
+
+# Overview
+Expensify's direct integration with Greenhouse allows you to automatically send candidates from Greenhouse to Expensify for easy reimbursement. The integration can set the candidate's recruiter or recruiting coordinator as approver in Expensify.
+
+## Prerequisites of the integration
+- You must be a Workspace Admin in Expensify and an Admin in Greenhouse with Developer Permissions to complete this connection. This can be the same person or two different people.
+- Each Greenhouse candidate record must have an email address in order to send to Expensify since we use this as the unique identifier in Expensify.
+- We highly recommend that you create a specific Expensify workspace for candidates so that you can set up a separate workflow and a different set of Categories and Tags from what your employees would see.
+
+# How to connect Greenhouse to Expensify
+## Establish the connection from Expensify
+
+1. Log into Expensify as a Workspace admin and navigate to **Settings > Workspaces > _[Workspace Name]_ > Connections**
+2. Under Greenhouse, click **Connect to Greenhouse** then click **Sync with Greenhouse**, which will open the "Greenhouse Integration" instructions page in a new browser window
+
+## Create the Web hook
+
+1. Click the link under Step 1 on the Greenhouse Integration instructions page, or log into your Greenhouse account and navigate to **Configure > Dev Center > Web Hooks > Web Hooks**.
+2. After landing on the "Create a New Web Hook" page, follow the steps on the Greenhouse Integration instructions page to create the web hook.
+
+## Create the custom candidate field
+
+1. Click the link under Step 2 on the Greenhouse Integration instructions page, or log into your Greenhouse account and navigate to **Configure > Custom Options > Custom Company Fields > Candidates**
+2. Follow the steps on the Greenhouse Integration instructions page to create the custom Candidate field.
+3. Click **Finish** (Step 3 on the Greenhouse Integration instructions page) to finish connecting Greenhouse with Expensify.
+
+# How to send candidates from Greenhouse to Expensify
+## In Greenhouse:
+
+1. Log into Greenhouse and go to any candidate’s Details tab
+2. Confirm that the Email field is filled in
+3. Optionally select the Recruiter field to set the recruiter as the candidate's expense approver in Expensify (Note: if you'd prefer to have the Recruiting Coordinator used as the default approver, please reach out to concierge@expensify.com or your account manager to request that we change the default approver on your behalf)
+4. Send this candidate to Expensify by toggling the **Invite to Expensify** field to **Yes** and clicking **Save**
+
+## In Expensify:
+
+1. Navigate to **Settings > Policies > Group > _[Workspace Name]_ > Members**
+2. The candidate you just sent to Expensify should be listed in the workspace members list
+3. If the Recruiter (or Recruiting Coordinator) field was filled in in Greenhouse, the candidate will already be configured to submit reports to that recruiter for approval. If no Recruiter was selected, then the candidate will submit based on the Expensify workspace approval settings.
diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Rippling.md b/docs/articles/expensify-classic/integrations/HR-integrations/Rippling.md
index 3ee1c8656b4b..fa4aaec3376f 100644
--- a/docs/articles/expensify-classic/integrations/HR-integrations/Rippling.md
+++ b/docs/articles/expensify-classic/integrations/HR-integrations/Rippling.md
@@ -1,5 +1,13 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Rippling Integration
+description: Sync employee and expense data between Expensify and Rippling
---
-## Resource Coming Soon!
+# Overview
+The Rippling integration allows mutual customers to sync employee and expense data between Expensify and Rippling. The Rippling integration allows you to:
+1. **Automate Employee Management:** Automatically create and remove employee accounts in Expensify directly from Rippling.
+2. **Simplify Employee Access to Expensify:** Employees can sign into Expensify from their Rippling SSO bar using SAML single sign-on.
+3. **Import Reimbursable Expense Reports:** Admins can export reimbursable expense reports from Expensify directly into Rippling Payroll.
+
+# How to use the Rippling integration
+The Rippling team manages this integration. To connect your Expensify workspace with Rippling, please visit the Rippling App Shop and sign into your Rippling account.
+For instructions on how to connect, and for troubleshooting the integration, please contact the Rippling support team by emailing support@rippling.com.
diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/Indirect-Accounting-Integrations.md b/docs/articles/expensify-classic/integrations/accounting-integrations/Indirect-Accounting-Integrations.md
new file mode 100644
index 000000000000..852db0b7f7c0
--- /dev/null
+++ b/docs/articles/expensify-classic/integrations/accounting-integrations/Indirect-Accounting-Integrations.md
@@ -0,0 +1,48 @@
+---
+title: Indirect Accounting Integrations
+description: Learn how to export your expenses and reports to a built-for-purpose flat file that works with your accounting platform.
+---
+
+
+# Overview
+
+Along with the direct integrations Expensify supports, there's also an option to integrate with other accounting solutions via a flat-file import.
+
+When you set up one of these accounting packages in Expensify, we will automatically create and add a relevant export template. The template will allow you to quickly and easily transfer expense and report data to your accounting package.
+
+# How to Set Up an Indirect Accounting Integration
+
+## Home Page
+
+After selecting your Group Plan type for your first workspace, you'll be taken through a few workspace setup tasks on the home page. When you reach the **Accounting Software** task, select your accounting solution from the available options.
+
+You'll receive a confirmation message, and the respective export template will be added to the account. From then on, it will show in the **Export to** option on the **Reports** page and at the top of each report.
+
+## Workspace Settings
+
+Head to **Settings** > **Workspaces** > **Group** > _Your desired workspace_ > **Connections** and select an accounting package from the options listed here. You'll receive a confirmation message, and the respective export template will be added to the account. From then on, it will show in the **Export to** option on the **Reports** page and at the top of each report.
+
+# How to Export a Report for My Accounting Package
+
+You can export reports to these templates in two ways:
+
+To export a report, click **Export To** in the top-left of a report and select your accounting package from the dropdown menu.
+
+To export multiple reports, tick the checkbox next to the reports on the **Reports** page, then click **Export To** and select your accounting package from the dropdown menu.
+
+# FAQ
+
+## Which accounting packages offer this indirect integration with Expensify?
+
+We support a pre-configured flat-file integration for the following accounting packages:
+
+ - Sage
+ - Microsoft Dynamics
+ - MYOB
+ - Oracle
+ - SAP
+
+## What if my accounting package isn’t listed here?
+
+If your accounting package isn’t listed, but it still accepts a flat-file import, select **Other** when completing the Accounting Software task on your Home page or head to **Settings** > **Workspaces** > **Group** > _Your desired workspace_ > **Export Formats**. This option allows you to create your own templates to export your expense and report data into a format compatible with your accounting system.
+
diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md
index a65dc378a793..65238457f1a9 100644
--- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md
+++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md
@@ -9,34 +9,34 @@ This guide is for those who are part of a **Group Workspace**.
Each member has a role that defines what they can see and do in the workspace. Most members will have the role of "Employee."
-# How to Manage User Roles
+# How to manage user roles
-To find and edit the roles of group workspace members, go to **Settings > Workspaces > Group > [Your Specific Workspace Name] > Members > Workspace Members**
+To find and edit the roles of group workspace members, go to **Settings > Workspaces > Group > _[Workspace Name]_ > Members > Workspace Members**
Here you'll see the list of members in your group workspace. To change their roles, click **Settings** next to the member’s name and choose the role that the member needs.
Next, let’s go over the various user roles that are available on a group workspace.
-## The Employee Role
+### The Employee Role
- **What can they do:** Employees can only see their own expense reports or reports that have been submitted to or shared with them. They can't change settings or invite new users.
- **Who is it for:** Regular employees who only need to manage their own expenses, or managers who are reviewing expense reports for a few users but don’t need global visibility.
- **Approvers:** Members who approve expenses can either be Employees, Admins, or Workspace Auditors, depending on how much control they need.
- **Billable:** Employees are billable actors if they take actions on a report on your Group Workspace (including **SmartScanning** a receipt).
-## Workspace Admin Role
+### Workspace Admin Role
- **What can they do:** Admins have full control. They can change settings, invite members, and view all reports. They can also process reimbursements if they have access to the company’s account.
- **Billing Owners:** Billing owners are Admins by default. **Workspace Admins** are assigned by the owner or another admin.
- **Billable:** Yes, if they perform actions like changing settings or inviting users. Just viewing reports is not billable.
-## Workspace Auditor Role
+### Workspace Auditor Role
- **What can they do:** Workspace Auditors can see all reports, make comments, and export them. They can also mark reports as reimbursed if they're the final approver.
- **Who is it for:** Accountants, bookkeepers, and internal or external audit agents who need to view but not edit workspace settings.
- **Billable:** Yes, if they perform any actions like commenting or exporting a report. Viewing alone doesn't incur a charge.
-## Technical Contact
+### Technical Contact
- **What can they do:** In case of connection issues, alerts go to the billing owner by default. You can set a technical contact if you want alerts to go to an IT administrator instead.
- **How to set one:** Go to **Settings > Workspaces > Group > [Workspace Name] > Connections > Technical Contact**.
diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md
index e107734216f5..7c21b12a83e1 100644
--- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md
+++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md
@@ -1,8 +1,54 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Vacation Delegate
+description: In Expensify, a vacation delegate is someone you choose to act on your behalf when you're on vacation or taking personal time off.
---
-## Resource Coming Soon!
+
+# Overview
+
+A delegate is someone who can handle approving expense reports for you, which is especially useful when you're out of the office!
+
+In Expensify, a **Vacation Delegate** is someone you choose to act on your behalf when you're on vacation or taking personal time off. They will approve expense reports just like you would, and everything moves forward as usual afterward.
+
+The system keeps a detailed audit trail, showing exactly when your delegate stepped in to approve a report for you. And if your delegate also goes on vacation, they can have their own delegate, so reports keep getting approved.
+
+By using this feature, you ensure that all reports get the approvals they need, even when you're not around.
+
+# How to use Vacation Delegate
+
+If you're planning to take some time off, you can use the **Vacation Delegate** feature to assign someone to approve expense reports for you. The reports will continue on their usual path as if you had approved them yourself.
+
+## Set a Vacation Delegate for yourself
+
+1. Go to the Expensify website (note: you can't do this from the mobile app).
+2. Navigate to **Settings > Your Account > Account Details** and scroll down to find **Vacation Delegate**.
+3. Enter the email address of the person you're designating as your delegate and click **Set Delegate**.
+
+Voila! You've set a vacation delegate. Any reports that usually come to you will now go to your delegate instead. When you return, you can easily remove the delegate by clicking a link at the top of the Expensify homepage.
+
+## Setting a Vacation Delegate as a Domain Admin
+
+1. Head to **Settings > Domains > [Your Domain Name] > Domain Members > Edit Settings**
+2. Enter the delegate's email address and click **Save.**
+
+Your delegate's actions will be noted in the history and comments of each report they approve, so you can keep track of what happened while you were away.
+
+# Deep Dive
+
+## An audit trail of delegate actions
+
+The system records every action your vacation delegate takes on your behalf in the **Report History and Comments**. So, you can see when they approved an expense report for you.
+
+# FAQs
+
+## Why can't my Vacation Delegate reimburse reports that they approve?
+
+If your **Vacation Delegate** also needs to reimburse reports on your behalf whilst you're away, they'll also need access to the reimbursement account.
+
+If they do not have access to the reimbursement account used on your workspace, they won’t have the option to reimburse reports, even as your **Vacation Delegate**.
+
+## What if my Vacation Delegate is also on vacation?
+
+Don't worry, your delegate can also pick their own **Vacation Delegate**. This way, expense reports continue to get approved even if multiple people are away.
+
-Kayak.md Lyft.md TrainLine.md TravelPerk.md Trip Actions.md TripCatcher.md Uber.md
\ No newline at end of file
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md b/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md
deleted file mode 100644
index cea96cfe2057..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Admins
-description: Admins
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md b/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md b/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md b/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md b/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md b/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Currency.md b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Currency.md
new file mode 100644
index 000000000000..e5c9096fa610
--- /dev/null
+++ b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Currency.md
@@ -0,0 +1,5 @@
+---
+title: Currency
+description: Currency
+---
+## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Report-Fields-And-Titles.md b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Report-Fields-And-Titles.md
new file mode 100644
index 000000000000..e79e30ce42c9
--- /dev/null
+++ b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Report-Fields-And-Titles.md
@@ -0,0 +1,43 @@
+---
+title: Report Fields & Titles
+description: This article is about managing Report Fields and Report Titles in Expensify
+---
+# Overview
+
+In this article, we'll go over how to use Report Titles and Report Fields.
+
+## How to use Report Titles
+
+Default report titles enable group workspace admins or individual workspace users to establish a standardized title format for reports associated with a specific workspace. Additionally, admins have the choice to enforce these report titles, preventing employees from altering them. This ensures uniformity in report naming conventions during the review process, eliminating the need for employees to manually input titles for each report they generate.
+
+- Group workspace admins can set the Default Report Title from **Settings > Workspaces > Group > *[Workspace Name]* > Reports**.
+- Individual users can set the Default Report Title from **Settings > Workspaces > Individual > *[Workspace Name]* > Reports**.
+
+You can configure the title by using the formulas that we provide to populate the Report Title. Take a look at the help article on Custom Formulas to find all eligible formulas for your Report Titles.
+
+## Deep Dive on Report Titles
+
+Some formulas will automatically update the report title as changes are made to the report. For example, any formula related to dates, total amounts, workspace name, would adjust the title before the report is submitted for approval. Changes will not retroactively update report titles for reports which have been Approved or Reimbursed.
+
+To prevent report title editing by employees, simply enable "Enforce Default Report Title."
+
+## How to use Report Fields
+
+Report fields let you specify header-level details, distinct from tags which pertain to expenses on individual line items. These details can encompass specific project names, business trip information, locations, and more. Customize them according to your workspace's requirements.
+
+To set up Report Fields, follow these steps:
+- Workspace Admins can create report fields for group workspaces from **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Report and Invoice Fields**. For individual workspaces, follow **Settings > Workspaces > Individual > *[Workspace Name]* > Reports > Report and Invoice Fields**.
+- Under "Add New Field," enter the desired field name in the "Field Title" to describe the type of information to be selected.
+- Choose the appropriate input method under "Type":
+ - Text: Provides users with a free-text box to enter the requested information.
+ - Dropdown: Creates a selection of options for users to choose from.
+ - Date: Displays a clickable box that opens a calendar for users to select a date.
+
+## Deep Dive on Report Fields
+
+You cannot create these report fields directly in Expensify if you are connected to an accounting integration (QuickBooks Online, QuickBooks Desktop, Intacct, Xero, or NetSuite). Please refer to the relevant article for instructions on creating fields within that system.
+
+When report fields are configured on a workspace, they become mandatory information for associated reports. Leaving a report field empty or unselected will trigger a report violation, potentially blocking report submission or export.
+
+Report fields are "sticky," which means that any changes made by an employee will persist and be reflected in subsequent reports they create.
+
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit.md b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit.md
new file mode 100644
index 000000000000..c05df92bbbff
--- /dev/null
+++ b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit.md
@@ -0,0 +1,38 @@
+---
+title: Scheduled Submit
+description: How to use the Scheduled Submit feature
+---
+# Overview
+
+Scheduled Submit reduces the delay between the time an employee creates an expense to when it is submitted to the admin. This gives admins significantly faster visibility into employee spend. Without Scheduled Submit enabled, expenses can be left Unreported giving workspace admins no visibility into employee spend.
+
+The biggest delay in expense management is the time it takes for an employee to actually submit the expense after it is incurred. Scheduled Submit allows you to automatically collect employee expenses on a schedule of your choosing without delaying the process while you wait for employees to submit them.
+
+It works like this: Employee expenses are automatically gathered onto a report. If there is not an existing report, a new one will be created. This report is submitted automatically at the cadence you choose (daily, weekly, monthly, twice month, by trip).
+
+# How to enable Scheduled Submit
+
+**For workspace admins**: To enable Scheduled Submit on your group workspace, follow **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu.
+For individuals or employees: To enable Scheduled Submit on your individual workspace, follow **Settings > Workspaces > Individual > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu.
+
+## Scheduled Submit frequency options
+
+**Daily**: Each night, expenses without violations will be submitted. Expenses with violations will remain on an open report until the violations are corrected, after which they will be submitted in the evening (PDT).
+
+**Weekly**: Expenses that are free of violations will be submitted on a weekly basis. However, expenses with violations will be held in a new open report and combined with any new expenses. They will then be submitted at the end of the following weekly cycle, specifically on Sunday evening (PDT).
+
+**Twice a month**: Expenses that are violation-free will be submitted on both the 15th and the last day of each month, in the evening (PDT). Expenses with violations will not be submitted, but moved on to a new open report so the employee can resolve the violations and then will be submitted at the conclusion of the next cycle.
+
+**Monthly**: Expenses that are free from violations will be submitted on a monthly basis. Expenses with violations will be held back and moved to a new Open report so the violations can be resolved, and they will be submitted on the evening (PDT) of the specified date.
+
+**By trip**: Expenses are grouped by trip. This is calculated by grouping all expenses together that occur in a similar time frame. If two full days pass without any new expenses being created, the trip report will be submitted on the evening of the second day. Any expenses generated after this submission will initiate a new trip report. Please note that the "2-day" period refers to a date-based interval, not a 48-hour time frame.
+
+**Manually**: An open report will be created, and expenses will be added to it automatically. However, it's important to note that the report will not be submitted automatically; manual submission of reports will be required.This is a great option for automatically gathering all an employee’s expenses on a report for the employee’s convenience, but they will still need to review and submit the report.
+
+# Deep Dive
+
+## Schedule Submit Override
+If Scheduled Submit is disabled at the group workspace level or configured the frequency as "Manually," the individual workspace settings of a user will take precedence and be applied. This means an employee can still set up Scheduled Submit for themselves even if the admin has not enabled it. We highly recommend Scheduled Submit as it helps put your expenses on auto-pilot!
+
+## Personal Card Transactions
+Personal card transactions are handled differently compared to other expenses. If a user has added a card through Settings > Account > Credit Card Import, they need to make sure it is set as non-reimbursable and transactions must be automatically merged with a SmartScanned receipt. If transactions are set to come in as reimbursable or they aren’t merged with a SmartScanned receipt, Scheduled Submit settings will not apply.
diff --git a/docs/articles/new-expensify/billing-and-plan-types/The-Free-Plan.md b/docs/articles/new-expensify/billing-and-plan-types/The-Free-Plan.md
index 0a8d6b3493e0..e157ede1969d 100644
--- a/docs/articles/new-expensify/billing-and-plan-types/The-Free-Plan.md
+++ b/docs/articles/new-expensify/billing-and-plan-types/The-Free-Plan.md
@@ -1,6 +1,7 @@
---
title: The Free Plan
description: Everything you need to know about Expensify's Free Plan!
+redirect_from: articles/split-bills/workspaces/The-Free-Plan/
---
diff --git a/docs/articles/new-expensify/get-paid-back/Request-Money.md b/docs/articles/new-expensify/get-paid-back/Request-Money.md
index dc6de6656cc9..a2b765915af0 100644
--- a/docs/articles/new-expensify/get-paid-back/Request-Money.md
+++ b/docs/articles/new-expensify/get-paid-back/Request-Money.md
@@ -1,5 +1,6 @@
---
title: Request Money
description: Request Money
+redirect_from: articles/request-money/Request-and-Split-Bills/
---
## Resource Coming Soon!
diff --git a/docs/articles/new-expensify/getting-started/Expensify-Lounge.md b/docs/articles/new-expensify/getting-started/Expensify-Lounge.md
index 01a2d7a9e250..bdccbe927769 100644
--- a/docs/articles/new-expensify/getting-started/Expensify-Lounge.md
+++ b/docs/articles/new-expensify/getting-started/Expensify-Lounge.md
@@ -1,6 +1,7 @@
---
title: Welcome to the Expensify Lounge!
description: How to get the most out of the Expensify Lounge.
+redirect_from: articles/other/Expensify-Lounge/
---
diff --git a/docs/articles/new-expensify/getting-started/chat/Everything-About-Chat.md b/docs/articles/new-expensify/getting-started/chat/Everything-About-Chat.md
index 9f73d1c759c2..77bbe54e8e2c 100644
--- a/docs/articles/new-expensify/getting-started/chat/Everything-About-Chat.md
+++ b/docs/articles/new-expensify/getting-started/chat/Everything-About-Chat.md
@@ -1,6 +1,7 @@
---
title: Everything About Chat
description: Everything you need to know about Expensify's Chat Features!
+redirect_from: articles/other/Everything-About-Chat/
---
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
index 31de150d5b5e..996d7896502f 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
@@ -1,6 +1,7 @@
---
title: Expensify Chat for Admins
description: Best Practices for Admins settings up Expensify Chat
+redirect_from: articles/other/Expensify-Chat-For-Admins/
---
## Overview
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
index 3d30237dca5a..20e15aaa6c72 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
@@ -1,6 +1,7 @@
---
title: Expensify Chat for Conference Attendees
description: Best Practices for Conference Attendees
+redirect_from: articles/other/Expensify-Chat-For-Conference-Attendees/
---
## Overview
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
index 5bd52425d92b..3e19cf6fe26a 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
@@ -1,6 +1,7 @@
---
title: Expensify Chat for Conference Speakers
description: Best Practices for Conference Speakers
+redirect_from: articles/other/Expensify-Chat-For-Conference-Speakers/
---
## Overview
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
index 8f806bb03146..a81aef2044a2 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
@@ -1,6 +1,7 @@
---
title: Expensify Chat Playbook for Conferences
description: Best practices for how to deploy Expensify Chat for your conference
+redirect_from: articles/playbooks/Expensify-Chat-Playbook-for-Conferences/
---
## Overview
To help make setting up Expensify Chat for your event and your attendees super simple, we’ve created a guide for all of the technical setup details.
diff --git a/docs/expensify-classic/hubs/policy-and-domain-settings/reports.html b/docs/expensify-classic/hubs/policy-and-domain-settings/reports.html
new file mode 100644
index 000000000000..86641ee60b7d
--- /dev/null
+++ b/docs/expensify-classic/hubs/policy-and-domain-settings/reports.html
@@ -0,0 +1,5 @@
+---
+layout: default
+---
+
+{% include section.html %}
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index c7d0f2f4f0f5..dac53193fdc6 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -17,7 +17,7 @@ platform :android do
desc "Generate a new local APK for e2e testing"
lane :build_e2e do
ENV["ENVFILE"]="tests/e2e/.env.e2e"
- ENV["ENTRY_FILE"]="#{Dir.pwd}/../src/libs/E2E/reactNativeLaunchingTest.js"
+ ENV["ENTRY_FILE"]="src/libs/E2E/reactNativeLaunchingTest.js"
ENV["E2E_TESTING"]="true"
gradle(
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 73e22053eda1..fb13a410dd8e 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.3.74
+ 1.3.75CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.74.3
+ 1.3.75.8ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 5e7f02699579..2168da376988 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.3.74
+ 1.3.75CFBundleSignature????CFBundleVersion
- 1.3.74.3
+ 1.3.75.8
diff --git a/package-lock.json b/package-lock.json
index 8c63ba6ce9b3..02d97c16521b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.74-3",
+ "version": "1.3.75-8",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.74-3",
+ "version": "1.3.75-8",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -81,16 +81,16 @@
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "2.12.0",
- "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#cef3ac29d9501091453136e1219e24c4ec9f9d76",
+ "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#c8c2a873335df19081056a5667f5c109583882e1",
"react-native-haptic-feedback": "^1.13.0",
"react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^5.1.0",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b",
- "react-native-key-command": "^1.0.1",
+ "react-native-key-command": "^1.0.5",
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.89",
+ "react-native-onyx": "1.0.97",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.7.1",
"react-native-performance": "^5.1.0",
@@ -41099,8 +41099,8 @@
},
"node_modules/react-native-google-places-autocomplete": {
"version": "2.5.1",
- "resolved": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#cef3ac29d9501091453136e1219e24c4ec9f9d76",
- "integrity": "sha512-2z3ED8jOXasPTzBqvPwpG10LQsBArTRsYszmoz+TfqbgZrSBmP3c8rhaC//lx6Pvfs2r+KYWqJUrLf4mbCrjZw==",
+ "resolved": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#c8c2a873335df19081056a5667f5c109583882e1",
+ "integrity": "sha512-jYQJlI5Pp/UI4k4Xy9fqnE0x4BC+O6c5Fh7I+7SjtaywA5KpZqQcYApx2e9YcH/igJ4Rdp/n4awKPX+vE5vFcg==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8",
@@ -41151,19 +41151,25 @@
"license": "MIT"
},
"node_modules/react-native-key-command": {
- "version": "1.0.1",
- "license": "MIT",
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.5.tgz",
+ "integrity": "sha512-SJWf1e8f3yGFrFDNCmJ+aiGmnwokGgtMicfvuyukhQtXkncCQb9pBI4uhBen0Bd30uMmUDgGAA9O56OyIdf5jw==",
"dependencies": {
- "events": "^3.3.0",
+ "eventemitter3": "^5.0.1",
"underscore": "^1.13.4"
},
"peerDependencies": {
"react": "^18.1.0",
"react-dom": "18.1.0",
"react-native": "^0.70.4",
- "react-native-web": "^0.18.1"
+ "react-native-web": "^0.19.7"
}
},
+ "node_modules/react-native-key-command/node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
+ },
"node_modules/react-native-linear-gradient": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.1.tgz",
@@ -41204,9 +41210,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "1.0.89",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.89.tgz",
- "integrity": "sha512-bSC8YwVbMBJYm6BMtuhuYmZi6zMh13e1t8Kaxp7K5EDLcSoTWsWPkuWX4wBvewlkLfw+HgB1IdgnXpa6+jS+ag==",
+ "version": "1.0.97",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.97.tgz",
+ "integrity": "sha512-6w4pp9Ktm4lQ6jIS+ZASQ5tYwRU1lt751yxfddvmN646XZefj4iDvC7uQaUnAgg1xL52dEV5RZWaI3sQ3e9AGQ==",
"dependencies": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -41491,21 +41497,23 @@
}
},
"node_modules/react-native-web": {
- "version": "0.18.12",
- "license": "MIT",
+ "version": "0.19.9",
+ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.9.tgz",
+ "integrity": "sha512-m69arZbS6FV+BNSKE6R/NQwUX+CzxCkYM7AJlSLlS8dz3BDzlaxG8Bzqtzv/r3r1YFowhnZLBXVKIwovKDw49g==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.6",
- "create-react-class": "^15.7.0",
+ "@react-native/normalize-color": "^2.1.0",
"fbjs": "^3.0.4",
"inline-style-prefixer": "^6.0.1",
- "normalize-css-color": "^1.0.2",
+ "memoize-one": "^6.0.0",
+ "nullthrows": "^1.1.1",
"postcss-value-parser": "^4.2.0",
- "styleq": "^0.1.2"
+ "styleq": "^0.1.3"
},
"peerDependencies": {
- "react": "^17.0.2 || ^18.0.0",
- "react-dom": "^17.0.2 || ^18.0.0"
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
}
},
"node_modules/react-native-web-linear-gradient": {
@@ -41526,6 +41534,12 @@
"react-native-web": "*"
}
},
+ "node_modules/react-native-web/node_modules/memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+ "peer": true
+ },
"node_modules/react-native-webview": {
"version": "11.23.0",
"license": "MIT",
@@ -45183,8 +45197,9 @@
}
},
"node_modules/styleq": {
- "version": "0.1.2",
- "license": "MIT"
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
+ "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA=="
},
"node_modules/sudo-prompt": {
"version": "9.2.1",
@@ -77213,9 +77228,9 @@
}
},
"react-native-google-places-autocomplete": {
- "version": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#cef3ac29d9501091453136e1219e24c4ec9f9d76",
- "integrity": "sha512-2z3ED8jOXasPTzBqvPwpG10LQsBArTRsYszmoz+TfqbgZrSBmP3c8rhaC//lx6Pvfs2r+KYWqJUrLf4mbCrjZw==",
- "from": "react-native-google-places-autocomplete@git+https://github.com/Expensify/react-native-google-places-autocomplete.git#cef3ac29d9501091453136e1219e24c4ec9f9d76",
+ "version": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#c8c2a873335df19081056a5667f5c109583882e1",
+ "integrity": "sha512-jYQJlI5Pp/UI4k4Xy9fqnE0x4BC+O6c5Fh7I+7SjtaywA5KpZqQcYApx2e9YcH/igJ4Rdp/n4awKPX+vE5vFcg==",
+ "from": "react-native-google-places-autocomplete@git+https://github.com/Expensify/react-native-google-places-autocomplete.git#c8c2a873335df19081056a5667f5c109583882e1",
"requires": {
"lodash.debounce": "^4.0.8",
"prop-types": "^15.7.2",
@@ -77245,10 +77260,19 @@
"from": "react-native-image-size@git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b"
},
"react-native-key-command": {
- "version": "1.0.1",
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.5.tgz",
+ "integrity": "sha512-SJWf1e8f3yGFrFDNCmJ+aiGmnwokGgtMicfvuyukhQtXkncCQb9pBI4uhBen0Bd30uMmUDgGAA9O56OyIdf5jw==",
"requires": {
- "events": "^3.3.0",
+ "eventemitter3": "^5.0.1",
"underscore": "^1.13.4"
+ },
+ "dependencies": {
+ "eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
+ }
}
},
"react-native-linear-gradient": {
@@ -77269,9 +77293,9 @@
}
},
"react-native-onyx": {
- "version": "1.0.89",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.89.tgz",
- "integrity": "sha512-bSC8YwVbMBJYm6BMtuhuYmZi6zMh13e1t8Kaxp7K5EDLcSoTWsWPkuWX4wBvewlkLfw+HgB1IdgnXpa6+jS+ag==",
+ "version": "1.0.97",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.97.tgz",
+ "integrity": "sha512-6w4pp9Ktm4lQ6jIS+ZASQ5tYwRU1lt751yxfddvmN646XZefj4iDvC7uQaUnAgg1xL52dEV5RZWaI3sQ3e9AGQ==",
"requires": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -77439,16 +77463,27 @@
"requires": {}
},
"react-native-web": {
- "version": "0.18.12",
+ "version": "0.19.9",
+ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.9.tgz",
+ "integrity": "sha512-m69arZbS6FV+BNSKE6R/NQwUX+CzxCkYM7AJlSLlS8dz3BDzlaxG8Bzqtzv/r3r1YFowhnZLBXVKIwovKDw49g==",
"peer": true,
"requires": {
"@babel/runtime": "^7.18.6",
- "create-react-class": "^15.7.0",
+ "@react-native/normalize-color": "^2.1.0",
"fbjs": "^3.0.4",
"inline-style-prefixer": "^6.0.1",
- "normalize-css-color": "^1.0.2",
+ "memoize-one": "^6.0.0",
+ "nullthrows": "^1.1.1",
"postcss-value-parser": "^4.2.0",
- "styleq": "^0.1.2"
+ "styleq": "^0.1.3"
+ },
+ "dependencies": {
+ "memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+ "peer": true
+ }
}
},
"react-native-web-linear-gradient": {
@@ -79905,7 +79940,9 @@
}
},
"styleq": {
- "version": "0.1.2"
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
+ "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA=="
},
"sudo-prompt": {
"version": "9.2.1",
diff --git a/package.json b/package.json
index d013caa1c402..6159c827f7ca 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.74-3",
+ "version": "1.3.75-8",
"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.",
@@ -124,16 +124,16 @@
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "2.12.0",
- "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#cef3ac29d9501091453136e1219e24c4ec9f9d76",
+ "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#c8c2a873335df19081056a5667f5c109583882e1",
"react-native-haptic-feedback": "^1.13.0",
"react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^5.1.0",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b",
- "react-native-key-command": "^1.0.1",
+ "react-native-key-command": "^1.0.5",
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.89",
+ "react-native-onyx": "1.0.97",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.7.1",
"react-native-performance": "^5.1.0",
diff --git a/scripts/android-repackage-app-bundle-and-sign.sh b/scripts/android-repackage-app-bundle-and-sign.sh
index fe4ee1e4b8fc..1636edc21388 100755
--- a/scripts/android-repackage-app-bundle-and-sign.sh
+++ b/scripts/android-repackage-app-bundle-and-sign.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+source ./scripts/shellUtils.sh
###
# Takes an android app that has been built with the debug keystore,
@@ -41,7 +42,7 @@ if [ ! -f "$NEW_BUNDLE_FILE" ]; then
echo "Bundle file not found: $NEW_BUNDLE_FILE"
exit 1
fi
-OUTPUT_APK=$(realpath "$OUTPUT_APK")
+OUTPUT_APK=$(get_abs_path "$OUTPUT_APK")
# check if "apktool" command is available
if ! command -v apktool &> /dev/null
then
diff --git a/scripts/shellUtils.sh b/scripts/shellUtils.sh
index 876933af9766..4c9e2febc34d 100644
--- a/scripts/shellUtils.sh
+++ b/scripts/shellUtils.sh
@@ -41,3 +41,46 @@ function join_by_string {
shift
printf "%s" "$first" "${@/#/$separator}"
}
+
+# Usage: get_abs_path
+# Will make a path absolute, resolving any relative paths
+# example: get_abs_path "./foo/bar"
+get_abs_path() {
+ local the_path=$1
+ local -a path_elements
+ IFS='/' read -ra path_elements <<< "$the_path"
+
+ # If the path is already absolute, start with an empty string.
+ # We'll prepend the / later when reconstructing the path.
+ if [[ "$the_path" = /* ]]; then
+ abs_path=""
+ else
+ abs_path="$(pwd)"
+ fi
+
+ # Handle each path element
+ for element in "${path_elements[@]}"; do
+ if [ "$element" = "." ] || [ -z "$element" ]; then
+ continue
+ elif [ "$element" = ".." ]; then
+ # Remove the last element from abs_path
+ abs_path=$(dirname "$abs_path")
+ else
+ # Append element to the absolute path
+ abs_path="${abs_path}/${element}"
+ fi
+ done
+
+ # Remove any trailing '/'
+ while [[ $abs_path == */ ]]; do
+ abs_path=${abs_path%/}
+ done
+
+ # Special case for root
+ [ -z "$abs_path" ] && abs_path="/"
+
+ # Special case to remove any starting '//' when the input path was absolute
+ abs_path=${abs_path/#\/\//\/}
+
+ echo "$abs_path"
+}
\ No newline at end of file
diff --git a/src/App.js b/src/App.js
index 284c6115d7b8..1d2e07345c24 100644
--- a/src/App.js
+++ b/src/App.js
@@ -9,7 +9,7 @@ import {PickerStateProvider} from 'react-native-picker-select';
import CustomStatusBar from './components/CustomStatusBar';
import ErrorBoundary from './components/ErrorBoundary';
import Expensify from './Expensify';
-import {LocaleContextProvider} from './components/withLocalize';
+import {LocaleContextProvider} from './components/LocaleContextProvider';
import OnyxProvider from './components/OnyxProvider';
import HTMLEngineProvider from './components/HTMLEngineProvider';
import PopoverContextProvider from './components/PopoverProvider';
diff --git a/src/CONST.ts b/src/CONST.ts
index e2ec7f96a758..4627d12e6676 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -992,6 +992,11 @@ const CONST = {
STATEMENT: 'STATEMENT_NAVIGATE',
CONCIERGE: 'CONCIERGE_NAVIGATE',
},
+ MTL_WALLET_PROGRAM_ID: '760',
+ PROGRAM_ISSUERS: {
+ EXPENSIFY_PAYMENTS: 'Expensify Payments LLC',
+ BANCORP_BANK: 'The Bancorp Bank',
+ },
},
PLAID: {
@@ -1263,6 +1268,8 @@ const CONST = {
DATE_TIME_FORMAT: /^\d{2}-\d{2} \d{2}:\d{2} [AP]M$/,
ATTACHMENT_ROUTE: /\/r\/(\d*)\/attachment/,
ILLEGAL_FILENAME_CHARACTERS: /\/|<|>|\*|"|:|\?|\\|\|/g,
+
+ ENCODE_PERCENT_CHARACTER: /%(25)+/g,
},
PRONOUNS: {
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index d2b3031220f1..a1afc4fef2c1 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -1,5 +1,4 @@
import {ValueOf} from 'type-fest';
-import {OnyxUpdate} from 'react-native-onyx';
import DeepValueOf from './types/utils/DeepValueOf';
import * as OnyxTypes from './types/onyx';
import CONST from './CONST';
@@ -30,9 +29,6 @@ const ONYXKEYS = {
/** Note: These are Persisted Requests - not all requests in the main queue as the key name might lead one to believe */
PERSISTED_REQUESTS: 'networkRequestQueue',
- /** Onyx updates from a response, or success or failure data from a request. */
- QUEUED_ONYX_UPDATES: 'queuedOnyxUpdates',
-
/** Stores current date */
CURRENT_DATE: 'currentDate',
@@ -307,7 +303,6 @@ type OnyxValues = {
[ONYXKEYS.DEVICE_ID]: string;
[ONYXKEYS.IS_SIDEBAR_LOADED]: boolean;
[ONYXKEYS.PERSISTED_REQUESTS]: OnyxTypes.Request[];
- [ONYXKEYS.QUEUED_ONYX_UPDATES]: OnyxUpdate[];
[ONYXKEYS.CURRENT_DATE]: string;
[ONYXKEYS.CREDENTIALS]: OnyxTypes.Credentials;
[ONYXKEYS.IOU]: OnyxTypes.IOU;
diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js
index 1b4200572664..14d13a63eec3 100644
--- a/src/components/AddressSearch/index.js
+++ b/src/components/AddressSearch/index.js
@@ -291,6 +291,12 @@ function AddressSearch(props) {
{props.translate('common.noResultsFound')}
)
}
+ renderHeaderComponent={() =>
+ !props.value &&
+ props.predefinedPlaces && (
+ {props.translate('common.recentDestinations')}
+ )
+ }
onPress={(data, details) => {
saveLocationDetails(data, details);
diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js
index 44075a4ec1eb..e7b18bbd8d69 100755
--- a/src/components/Composer/index.js
+++ b/src/components/Composer/index.js
@@ -358,7 +358,7 @@ function Composer({
const paddingTopAndBottom = parseInt(computedStyle.paddingBottom, 10) + parseInt(computedStyle.paddingTop, 10);
setTextInputWidth(computedStyle.width);
- const computedNumberOfLines = ComposerUtils.getNumberOfLines(maxLines, lineHeight, paddingTopAndBottom, textInput.current.scrollHeight);
+ const computedNumberOfLines = ComposerUtils.getNumberOfLines(lineHeight, paddingTopAndBottom, textInput.current.scrollHeight, maxLines);
const generalNumberOfLines = computedNumberOfLines === 0 ? numberOfLinesProp : computedNumberOfLines;
onNumberOfLinesChange(generalNumberOfLines);
diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js
index 90f5c22e5b3c..5261d1258ad0 100644
--- a/src/components/Form/FormProvider.js
+++ b/src/components/Form/FormProvider.js
@@ -108,6 +108,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c
(values) => {
const validateErrors = validate(values);
setErrors(validateErrors);
+ return validateErrors;
},
[validate],
);
diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.js
index 38ea64952a2c..f39c44b278ae 100644
--- a/src/components/Hoverable/index.js
+++ b/src/components/Hoverable/index.js
@@ -1,103 +1,73 @@
import _ from 'underscore';
-import React, {Component} from 'react';
+import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react';
import {DeviceEventEmitter} from 'react-native';
import {propTypes, defaultProps} from './hoverablePropTypes';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
import CONST from '../../CONST';
+function mapChildren(children, callbackParam) {
+ if (_.isArray(children) && children.length === 1) {
+ return children[0];
+ }
+
+ if (_.isFunction(children)) {
+ return children(callbackParam);
+ }
+
+ return children;
+}
+
/**
* It is necessary to create a Hoverable component instead of relying solely on Pressable support for hover state,
* because nesting Pressables causes issues where the hovered state of the child cannot be easily propagated to the
* parent. https://github.com/necolas/react-native-web/issues/1875
*/
-class Hoverable extends Component {
- constructor(props) {
- super(props);
- this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
- this.checkHover = this.checkHover.bind(this);
+function InnerHoverable({disabled, onHoverIn, onHoverOut, children, shouldHandleScroll}, outerRef) {
+ const [isHovered, setIsHovered] = useState(false);
- this.state = {
- isHovered: false,
- };
+ const isScrolling = useRef(false);
+ const isHoveredRef = useRef(false);
+ const ref = useRef(null);
- this.isHoveredRef = false;
- this.isScrollingRef = false;
- this.wrapperView = null;
- }
+ const updateIsHoveredOnScrolling = useCallback(
+ (hovered) => {
+ if (disabled) {
+ return;
+ }
- componentDidMount() {
- document.addEventListener('visibilitychange', this.handleVisibilityChange);
- document.addEventListener('mouseover', this.checkHover);
-
- /**
- * Only add the scrolling listener if the shouldHandleScroll prop is true
- * and the scrollingListener is not already set.
- */
- if (!this.scrollingListener && this.props.shouldHandleScroll) {
- this.scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
- /**
- * If user has stopped scrolling and the isHoveredRef is true, then we should update the hover state.
- */
- if (!scrolling && this.isHoveredRef) {
- this.setState({isHovered: this.isHoveredRef}, this.props.onHoverIn);
- } else if (scrolling && this.isHoveredRef) {
- /**
- * If the user has started scrolling and the isHoveredRef is true, then we should set the hover state to false.
- * This is to hide the existing hover and reaction bar.
- */
- this.setState({isHovered: false}, this.props.onHoverOut);
- }
- this.isScrollingRef = scrolling;
- });
- }
- }
+ isHoveredRef.current = hovered;
- componentDidUpdate(prevProps) {
- if (prevProps.disabled === this.props.disabled) {
- return;
- }
+ if (shouldHandleScroll && isScrolling.current) {
+ return;
+ }
+ setIsHovered(hovered);
+ },
+ [disabled, shouldHandleScroll],
+ );
- if (this.props.disabled && this.state.isHovered) {
- this.setState({isHovered: false});
- }
- }
+ useEffect(() => {
+ const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false);
- componentWillUnmount() {
- document.removeEventListener('visibilitychange', this.handleVisibilityChange);
- document.removeEventListener('mouseover', this.checkHover);
- if (this.scrollingListener) {
- this.scrollingListener.remove();
- }
- }
-
- /**
- * Sets the hover state of this component to true and execute the onHoverIn callback.
- *
- * @param {Boolean} isHovered - Whether or not this component is hovered.
- */
- setIsHovered(isHovered) {
- if (this.props.disabled) {
- return;
- }
+ document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
- /**
- * Capture whther or not the user is hovering over the component.
- * We will use this to determine if we should update the hover state when the user has stopped scrolling.
- */
- this.isHoveredRef = isHovered;
+ return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
+ }, []);
- /**
- * If the isScrollingRef is true, then the user is scrolling and we should not update the hover state.
- */
- if (this.isScrollingRef && this.props.shouldHandleScroll && !this.state.isHovered) {
+ useEffect(() => {
+ if (!shouldHandleScroll) {
return;
}
- if (isHovered !== this.state.isHovered) {
- this.setState({isHovered}, isHovered ? this.props.onHoverIn : this.props.onHoverOut);
- }
- }
+ const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
+ isScrolling.current = scrolling;
+ if (!scrolling) {
+ setIsHovered(isHoveredRef.current);
+ }
+ });
+
+ return () => scrollingListener.remove();
+ }, [shouldHandleScroll]);
/**
* Checks the hover state of a component and updates it based on the event target.
@@ -105,85 +75,108 @@ class Hoverable extends Component {
* such as when an element is removed before the mouseleave event is triggered.
* @param {Event} e - The hover event object.
*/
- checkHover(e) {
- if (!this.wrapperView || !this.state.isHovered) {
+ const unsetHoveredIfOutside = useCallback(
+ (e) => {
+ if (!ref.current || !isHovered) {
+ return;
+ }
+
+ if (ref.current.contains(e.target)) {
+ return;
+ }
+
+ setIsHovered(false);
+ },
+ [isHovered],
+ );
+
+ useEffect(() => {
+ if (!DeviceCapabilities.hasHoverSupport()) {
return;
}
- if (this.wrapperView.contains(e.target)) {
- return;
- }
+ document.addEventListener('mouseover', unsetHoveredIfOutside);
- this.setIsHovered(false);
- }
+ return () => document.removeEventListener('mouseover', unsetHoveredIfOutside);
+ }, [unsetHoveredIfOutside]);
- handleVisibilityChange() {
- if (document.visibilityState !== 'hidden') {
+ useEffect(() => {
+ if (!disabled || !isHovered) {
return;
}
+ setIsHovered(false);
+ }, [disabled, isHovered]);
- this.setIsHovered(false);
- }
-
- render() {
- let child = this.props.children;
- if (_.isArray(this.props.children) && this.props.children.length === 1) {
- child = this.props.children[0];
+ useEffect(() => {
+ if (disabled) {
+ return;
}
-
- if (_.isFunction(child)) {
- child = child(this.state.isHovered);
+ if (onHoverIn && isHovered) {
+ return onHoverIn();
}
-
- if (!DeviceCapabilities.hasHoverSupport()) {
- return child;
+ if (onHoverOut && !isHovered) {
+ return onHoverOut();
}
-
- return React.cloneElement(React.Children.only(child), {
- ref: (el) => {
- this.wrapperView = el;
-
- // Call the original ref, if any
- const {ref} = child;
- if (_.isFunction(ref)) {
- ref(el);
- return;
- }
-
- if (_.isObject(ref)) {
- ref.current = el;
- }
- },
- onMouseEnter: (el) => {
- this.setIsHovered(true);
-
- if (_.isFunction(child.props.onMouseEnter)) {
- child.props.onMouseEnter(el);
- }
- },
- onMouseLeave: (el) => {
- this.setIsHovered(false);
-
- if (_.isFunction(child.props.onMouseLeave)) {
- child.props.onMouseLeave(el);
- }
- },
- onBlur: (el) => {
- // Check if the blur event occurred due to clicking outside the element
- // and the wrapperView contains the element that caused the blur and reset isHovered
- if (!this.wrapperView.contains(el.target) && !this.wrapperView.contains(el.relatedTarget)) {
- this.setIsHovered(false);
- }
-
- if (_.isFunction(child.props.onBlur)) {
- child.props.onBlur(el);
- }
- },
- });
+ }, [disabled, isHovered, onHoverIn, onHoverOut]);
+
+ // Expose inner ref to parent through outerRef. This enable us to use ref both in parent and child.
+ useImperativeHandle(outerRef, () => ref.current, []);
+
+ const child = useMemo(() => React.Children.only(mapChildren(children, isHovered)), [children, isHovered]);
+
+ const onMouseEnter = useCallback(
+ (el) => {
+ updateIsHoveredOnScrolling(true);
+
+ if (_.isFunction(child.props.onMouseEnter)) {
+ child.props.onMouseEnter(el);
+ }
+ },
+ [child.props, updateIsHoveredOnScrolling],
+ );
+
+ const onMouseLeave = useCallback(
+ (el) => {
+ updateIsHoveredOnScrolling(false);
+
+ if (_.isFunction(child.props.onMouseLeave)) {
+ child.props.onMouseLeave(el);
+ }
+ },
+ [child.props, updateIsHoveredOnScrolling],
+ );
+
+ const onBlur = useCallback(
+ (el) => {
+ // Check if the blur event occurred due to clicking outside the element
+ // and the wrapperView contains the element that caused the blur and reset isHovered
+ if (!ref.current.contains(el.target) && !ref.current.contains(el.relatedTarget)) {
+ setIsHovered(false);
+ }
+
+ if (_.isFunction(child.props.onBlur)) {
+ child.props.onBlur(el);
+ }
+ },
+ [child.props],
+ );
+
+ if (!DeviceCapabilities.hasHoverSupport()) {
+ return child;
}
+
+ return React.cloneElement(child, {
+ ref,
+ onMouseEnter,
+ onMouseLeave,
+ onBlur,
+ });
}
+const Hoverable = React.forwardRef(InnerHoverable);
+
Hoverable.propTypes = propTypes;
Hoverable.defaultProps = defaultProps;
+Hoverable.displayName = 'Hoverable';
export default Hoverable;
diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js
index a0c8b72d755a..810bbc86b5dc 100644
--- a/src/components/Icon/Expensicons.js
+++ b/src/components/Icon/Expensicons.js
@@ -19,6 +19,7 @@ import Camera from '../../../assets/images/camera.svg';
import Car from '../../../assets/images/car.svg';
import Cash from '../../../assets/images/cash.svg';
import ChatBubble from '../../../assets/images/chatbubble.svg';
+import ChatBubbles from '../../../assets/images/chatbubbles.svg';
import Checkmark from '../../../assets/images/checkmark.svg';
import Chair from '../../../assets/images/chair.svg';
import Close from '../../../assets/images/close.svg';
@@ -147,6 +148,7 @@ export {
Car,
Cash,
ChatBubble,
+ ChatBubbles,
Checkmark,
Chair,
Close,
diff --git a/src/components/LocaleContextProvider.js b/src/components/LocaleContextProvider.js
new file mode 100644
index 000000000000..b8838f253e74
--- /dev/null
+++ b/src/components/LocaleContextProvider.js
@@ -0,0 +1,135 @@
+import React, {createContext, useMemo} from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import lodashGet from 'lodash/get';
+
+import ONYXKEYS from '../ONYXKEYS';
+import * as Localize from '../libs/Localize';
+import DateUtils from '../libs/DateUtils';
+import * as NumberFormatUtils from '../libs/NumberFormatUtils';
+import * as LocaleDigitUtils from '../libs/LocaleDigitUtils';
+import CONST from '../CONST';
+import compose from '../libs/compose';
+import withCurrentUserPersonalDetails from './withCurrentUserPersonalDetails';
+import * as LocalePhoneNumber from '../libs/LocalePhoneNumber';
+
+const LocaleContext = createContext(null);
+
+const localeProviderPropTypes = {
+ /** The user's preferred locale e.g. 'en', 'es-ES' */
+ preferredLocale: PropTypes.string,
+
+ /** Actual content wrapped by this component */
+ children: PropTypes.node.isRequired,
+
+ /** The current user's personalDetails */
+ currentUserPersonalDetails: PropTypes.shape({
+ /** Timezone of the current user */
+ timezone: PropTypes.shape({
+ /** Value of the selected timezone */
+ selected: PropTypes.string,
+ }),
+ }),
+};
+
+const localeProviderDefaultProps = {
+ preferredLocale: CONST.LOCALES.DEFAULT,
+ currentUserPersonalDetails: {},
+};
+
+function LocaleContextProvider({children, currentUserPersonalDetails, preferredLocale}) {
+ const selectedTimezone = useMemo(() => lodashGet(currentUserPersonalDetails, 'timezone.selected'), [currentUserPersonalDetails]);
+
+ /**
+ * @param {String} phrase
+ * @param {Object} [variables]
+ * @returns {String}
+ */
+ const translate = useMemo(() => (phrase, variables) => Localize.translate(preferredLocale, phrase, variables), [preferredLocale]);
+
+ /**
+ * @param {Number} number
+ * @param {Intl.NumberFormatOptions} options
+ * @returns {String}
+ */
+ const numberFormat = useMemo(() => (number, options) => NumberFormatUtils.format(preferredLocale, number, options), [preferredLocale]);
+
+ /**
+ * @param {String} datetime
+ * @returns {String}
+ */
+ const datetimeToRelative = useMemo(() => (datetime) => DateUtils.datetimeToRelative(preferredLocale, datetime), [preferredLocale]);
+
+ /**
+ * @param {String} datetime - ISO-formatted datetime string
+ * @param {Boolean} [includeTimezone]
+ * @param {Boolean} isLowercase
+ * @returns {String}
+ */
+ const datetimeToCalendarTime = useMemo(
+ () =>
+ (datetime, includeTimezone, isLowercase = false) =>
+ DateUtils.datetimeToCalendarTime(preferredLocale, datetime, includeTimezone, selectedTimezone, isLowercase),
+ [preferredLocale, selectedTimezone],
+ );
+
+ /**
+ * Updates date-fns internal locale to the user preferredLocale
+ */
+ const updateLocale = useMemo(() => () => DateUtils.setLocale(preferredLocale), [preferredLocale]);
+
+ /**
+ * @param {String} phoneNumber
+ * @returns {String}
+ */
+ const formatPhoneNumber = LocalePhoneNumber.formatPhoneNumber;
+
+ /**
+ * @param {String} digit
+ * @returns {String}
+ */
+ const toLocaleDigit = useMemo(() => (digit) => LocaleDigitUtils.toLocaleDigit(preferredLocale, digit), [preferredLocale]);
+
+ /**
+ * @param {String} localeDigit
+ * @returns {String}
+ */
+ const fromLocaleDigit = useMemo(() => (localeDigit) => LocaleDigitUtils.fromLocaleDigit(preferredLocale, localeDigit), [preferredLocale]);
+
+ /**
+ * The context this component exposes to child components
+ * @returns {object} translation util functions and locale
+ */
+ const contextValue = useMemo(
+ () => ({
+ translate,
+ numberFormat,
+ datetimeToRelative,
+ datetimeToCalendarTime,
+ updateLocale,
+ formatPhoneNumber,
+ toLocaleDigit,
+ fromLocaleDigit,
+ preferredLocale,
+ }),
+ [translate, numberFormat, datetimeToRelative, datetimeToCalendarTime, updateLocale, formatPhoneNumber, toLocaleDigit, fromLocaleDigit, preferredLocale],
+ );
+
+ return {children};
+}
+
+LocaleContextProvider.propTypes = localeProviderPropTypes;
+LocaleContextProvider.defaultProps = localeProviderDefaultProps;
+
+const Provider = compose(
+ withCurrentUserPersonalDetails,
+ withOnyx({
+ preferredLocale: {
+ key: ONYXKEYS.NVP_PREFERRED_LOCALE,
+ },
+ }),
+)(LocaleContextProvider);
+
+Provider.displayName = 'withOnyx(LocaleContextProvider)';
+
+export {Provider as LocaleContextProvider, LocaleContext};
diff --git a/src/components/PopoverMenu/index.js b/src/components/PopoverMenu/index.js
index 74d8c7fa8498..840e777b2479 100644
--- a/src/components/PopoverMenu/index.js
+++ b/src/components/PopoverMenu/index.js
@@ -103,6 +103,7 @@ function PopoverMenu(props) {
icon={item.icon}
iconWidth={item.iconWidth}
iconHeight={item.iconHeight}
+ iconFill={item.iconFill}
title={item.text}
description={item.description}
onPress={() => selectItem(menuIndex)}
diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js
index 20ccd49620b1..9f82c2000dcf 100644
--- a/src/components/ReportActionItem/TaskPreview.js
+++ b/src/components/ReportActionItem/TaskPreview.js
@@ -8,6 +8,7 @@ import compose from '../../libs/compose';
import styles from '../../styles/styles';
import ONYXKEYS from '../../ONYXKEYS';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
+import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../withCurrentUserPersonalDetails';
import Icon from '../Icon';
import CONST from '../../CONST';
import * as Expensicons from '../Icon/Expensicons';
@@ -23,6 +24,7 @@ import RenderHTML from '../RenderHTML';
import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback';
import personalDetailsPropType from '../../pages/personalDetailsPropType';
import * as Session from '../../libs/actions/Session';
+import * as LocalePhoneNumber from '../../libs/LocalePhoneNumber';
const propTypes = {
/** All personal details asssociated with user */
@@ -51,9 +53,12 @@ const propTypes = {
}),
...withLocalizePropTypes,
+
+ ...withCurrentUserPersonalDetailsPropTypes,
};
const defaultProps = {
+ ...withCurrentUserPersonalDetailsDefaultProps,
personalDetailsList: {},
taskReport: {},
isHovered: false,
@@ -70,7 +75,7 @@ function TaskPreview(props) {
const taskAssigneeAccountID = Task.getTaskAssigneeAccountID(props.taskReport) || props.action.childManagerAccountID;
const assigneeLogin = lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'login'], '');
const assigneeDisplayName = lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'displayName'], '');
- const taskAssignee = assigneeLogin || assigneeDisplayName;
+ const taskAssignee = assigneeDisplayName || LocalePhoneNumber.formatPhoneNumber(assigneeLogin);
const htmlForTaskPreview = taskAssignee ? `@${taskAssignee} ${taskTitle}` : `${taskTitle}`;
const isDeletedParentAction = ReportUtils.isCanceledTaskReport(props.taskReport, props.action);
@@ -91,7 +96,7 @@ function TaskPreview(props) {
style={[styles.mr2]}
containerStyle={[styles.taskCheckbox]}
isChecked={isTaskCompleted}
- disabled={ReportUtils.isCanceledTaskReport(props.taskReport)}
+ disabled={!Task.canModifyTask(props.taskReport, props.currentUserPersonalDetails.accountID)}
onPress={Session.checkIfActionIsAllowed(() => {
if (isTaskCompleted) {
Task.reopenTask(props.taskReport);
@@ -118,6 +123,7 @@ TaskPreview.displayName = 'TaskPreview';
export default compose(
withLocalize,
+ withCurrentUserPersonalDetails,
withOnyx({
taskReport: {
key: ({taskReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`,
diff --git a/src/components/ReportActionItem/TaskView.js b/src/components/ReportActionItem/TaskView.js
index c52427ae1e8d..7cddc7a969dc 100644
--- a/src/components/ReportActionItem/TaskView.js
+++ b/src/components/ReportActionItem/TaskView.js
@@ -49,9 +49,8 @@ function TaskView(props) {
const taskTitle = convertToLTR(props.report.reportName || '');
const isCompleted = ReportUtils.isCompletedTaskReport(props.report);
const isOpen = ReportUtils.isOpenTaskReport(props.report);
- const isCanceled = ReportUtils.isCanceledTaskReport(props.report);
const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID);
- const disableState = !canModifyTask || isCanceled;
+ const disableState = !canModifyTask;
const isDisableInteractive = !canModifyTask || !isOpen;
return (
@@ -102,7 +101,7 @@ function TaskView(props) {
containerBorderRadius={8}
caretSize={16}
accessibilityLabel={taskTitle || props.translate('task.task')}
- disabled={isCanceled || !canModifyTask}
+ disabled={!canModifyTask}
/>
{}}) {
+ const isUserItem = lodashGet(item, 'icons.length', 0) > 0;
+ const ListItem = isUserItem ? UserListItem : RadioListItem;
+
+ return (
+ onDismissError(item)}
+ pendingAction={item.pendingAction}
+ errors={item.errors}
+ errorRowStyles={styles.ph5}
+ >
+ onSelectRow(item)}
+ disabled={isDisabled}
+ accessibilityLabel={item.text}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ hoverDimmingValue={1}
+ hoverStyle={styles.hoveredComponentBG}
+ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
+ >
+
+ {canSelectMultiple && (
+
+
+ {item.isSelected && (
+
+ )}
+
+
+ )}
+
+
+
+ {!canSelectMultiple && item.isSelected && (
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+BaseListItem.displayName = 'BaseListItem';
+BaseListItem.propTypes = baseListItemPropTypes;
+
+export default BaseListItem;
diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js
index 8d894e4c983a..ebb95475bcd9 100644
--- a/src/components/SelectionList/BaseSelectionList.js
+++ b/src/components/SelectionList/BaseSelectionList.js
@@ -7,12 +7,9 @@ import SectionList from '../SectionList';
import Text from '../Text';
import styles from '../../styles/styles';
import TextInput from '../TextInput';
-import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
import CONST from '../../CONST';
import variables from '../../styles/variables';
import {propTypes as selectionListPropTypes} from './selectionListPropTypes';
-import RadioListItem from './RadioListItem';
-import UserListItem from './UserListItem';
import useKeyboardShortcut from '../../hooks/useKeyboardShortcut';
import SafeAreaConsumer from '../SafeAreaConsumer';
import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState';
@@ -24,6 +21,9 @@ import useLocalize from '../../hooks/useLocalize';
import Log from '../../libs/Log';
import OptionsListSkeletonView from '../OptionsListSkeletonView';
import useActiveElement from '../../hooks/useActiveElement';
+import BaseListItem from './BaseListItem';
+import themeColors from '../../styles/themes/default';
+import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
const propTypes = {
...keyboardStatePropTypes,
@@ -48,10 +48,13 @@ function BaseSelectionList({
headerMessage = '',
confirmButtonText = '',
onConfirm,
+ footerContent,
showScrollIndicator = false,
showLoadingPlaceholder = false,
showConfirmButton = false,
isKeyboardShown = false,
+ disableKeyboardShortcuts = false,
+ children,
}) {
const {translate} = useLocalize();
const firstLayoutRef = useRef(true);
@@ -136,19 +139,19 @@ function BaseSelectionList({
};
}, [canSelectMultiple, sections]);
- // Disable `Enter` hotkey if the active element is a button or checkbox
- const shouldDisableHotkeys = activeElement && [CONST.ACCESSIBILITY_ROLE.BUTTON, CONST.ACCESSIBILITY_ROLE.CHECKBOX].includes(activeElement.role);
-
// If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member
const [focusedIndex, setFocusedIndex] = useState(() => _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey));
+ // Disable `Enter` shortcut if the active element is a button or checkbox
+ const disableEnterShortcut = activeElement && [CONST.ACCESSIBILITY_ROLE.BUTTON, CONST.ACCESSIBILITY_ROLE.CHECKBOX].includes(activeElement.role);
+
/**
* Scrolls to the desired item index in the section list
*
* @param {Number} index - the index of the item to scroll to
* @param {Boolean} animated - whether to animate the scroll
*/
- const scrollToIndex = (index, animated) => {
+ const scrollToIndex = useCallback((index, animated = true) => {
const item = flattenedSections.allOptions[index];
if (!listRef.current || !item) {
@@ -169,7 +172,10 @@ function BaseSelectionList({
}
listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight});
- };
+
+ // If we don't disable dependencies here, we would need to make sure that the `sections` prop is stable in every usage of this component.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
/**
* Logic to run when a row is selected, either with click/press or keyboard hotkeys.
@@ -234,6 +240,14 @@ function BaseSelectionList({
const getItemLayout = (data, flatDataArrayIndex) => {
const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex];
+ if (!targetItem) {
+ return {
+ length: 0,
+ offset: 0,
+ index: flatDataArrayIndex,
+ };
+ }
+
return {
length: targetItem.length,
offset: targetItem.offset,
@@ -259,33 +273,40 @@ function BaseSelectionList({
const renderItem = ({item, index, section}) => {
const normalizedIndex = index + lodashGet(section, 'indexOffset', 0);
- const isDisabled = section.isDisabled;
+ const isDisabled = section.isDisabled || item.isDisabled;
const isItemFocused = !isDisabled && focusedIndex === normalizedIndex;
// We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade.
const showTooltip = normalizedIndex < 10;
- if (canSelectMultiple) {
- return (
- selectRow(item, true)}
- onDismissError={onDismissError}
- showTooltip={showTooltip}
- />
- );
- }
-
return (
- selectRow(item, true)}
+ onDismissError={onDismissError}
/>
);
};
+ const scrollToFocusedIndexOnFirstRender = useCallback(() => {
+ if (!firstLayoutRef.current) {
+ return;
+ }
+ scrollToIndex(focusedIndex, false);
+ firstLayoutRef.current = false;
+ }, [focusedIndex, scrollToIndex]);
+
+ const updateAndScrollToFocusedIndex = useCallback(
+ (newFocusedIndex) => {
+ setFocusedIndex(newFocusedIndex);
+ scrollToIndex(newFocusedIndex, true);
+ },
+ [scrollToIndex],
+ );
+
/** Focuses the text input when the component comes into focus and after any navigation animations finish. */
useFocusEffect(
useCallback(() => {
@@ -305,14 +326,14 @@ function BaseSelectionList({
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
captureOnInputs: true,
shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
- isActive: !shouldDisableHotkeys && isFocused,
+ isActive: !disableKeyboardShortcuts && !disableEnterShortcut && isFocused,
});
/** Calls confirm action when pressing CTRL (CMD) + Enter */
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, onConfirm, {
captureOnInputs: true,
shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
- isActive: Boolean(onConfirm) && isFocused,
+ isActive: !disableKeyboardShortcuts && Boolean(onConfirm) && isFocused,
});
return (
@@ -320,10 +341,7 @@ function BaseSelectionList({
disabledIndexes={flattenedSections.disabledOptionsIndexes}
focusedIndex={focusedIndex}
maxIndex={flattenedSections.allOptions.length - 1}
- onFocusedIndexChanged={(newFocusedIndex) => {
- setFocusedIndex(newFocusedIndex);
- scrollToIndex(newFocusedIndex, true);
- }}
+ onFocusedIndexChanged={updateAndScrollToFocusedIndex}
>
{({safeAreaPaddingBottomStyle}) => (
@@ -360,7 +378,7 @@ function BaseSelectionList({
style={[styles.peopleRow, styles.userSelectNone, styles.ph5, styles.pb3]}
onPress={onSelectAll}
accessibilityLabel={translate('workspace.people.selectAll')}
- accessibilityRole="button"
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityState={{checked: flattenedSections.allSelected}}
disabled={flattenedSections.allOptions.length === flattenedSections.disabledOptionsIndexes.length}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
@@ -387,7 +405,7 @@ function BaseSelectionList({
onScrollBeginDrag={onScrollBeginDrag}
keyExtractor={(item) => item.keyForList}
extraData={focusedIndex}
- indicatorStyle="white"
+ indicatorStyle={themeColors.selectionListIndicatorColor}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={showScrollIndicator}
initialNumToRender={12}
@@ -395,18 +413,14 @@ function BaseSelectionList({
windowSize={5}
viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
testID="selection-list"
- onLayout={() => {
- if (!firstLayoutRef.current) {
- return;
- }
- scrollToIndex(focusedIndex, false);
- firstLayoutRef.current = false;
- }}
+ style={[styles.flexGrow0]}
+ onLayout={scrollToFocusedIndexOnFirstRender}
/>
+ {children}
>
)}
{showConfirmButton && (
-
+
)}
+ {Boolean(footerContent) && {footerContent}}
)}
diff --git a/src/components/SelectionList/RadioListItem.js b/src/components/SelectionList/RadioListItem.js
index 530af66d91d3..83d0fc922f08 100644
--- a/src/components/SelectionList/RadioListItem.js
+++ b/src/components/SelectionList/RadioListItem.js
@@ -1,51 +1,18 @@
import React from 'react';
import {View} from 'react-native';
-import CONST from '../../CONST';
-import PressableWithFeedback from '../Pressable/PressableWithFeedback';
import styles from '../../styles/styles';
import Text from '../Text';
-import Icon from '../Icon';
-import * as Expensicons from '../Icon/Expensicons';
-import themeColors from '../../styles/themes/default';
import {radioListItemPropTypes} from './selectionListPropTypes';
-function RadioListItem({item, isFocused = false, isDisabled = false, onSelectRow}) {
+function RadioListItem({item, isFocused = false}) {
return (
- onSelectRow(item)}
- disabled={isDisabled}
- accessibilityLabel={item.text}
- accessibilityRole="button"
- hoverDimmingValue={1}
- hoverStyle={styles.hoveredComponentBG}
- dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
- >
-
-
-
- {item.text}
-
+
+ {item.text}
- {Boolean(item.alternateText) && (
- {item.alternateText}
- )}
-
-
- {item.isSelected && (
-
-
-
-
-
- )}
-
-
+ {Boolean(item.alternateText) && (
+ {item.alternateText}
+ )}
+
);
}
diff --git a/src/components/SelectionList/UserListItem.js b/src/components/SelectionList/UserListItem.js
index 0d37162a7995..436ae8cb056b 100644
--- a/src/components/SelectionList/UserListItem.js
+++ b/src/components/SelectionList/UserListItem.js
@@ -1,108 +1,50 @@
import React from 'react';
import {View} from 'react-native';
-import _ from 'underscore';
import lodashGet from 'lodash/get';
-import PressableWithFeedback from '../Pressable/PressableWithFeedback';
import styles from '../../styles/styles';
import Text from '../Text';
import {userListItemPropTypes} from './selectionListPropTypes';
-import Avatar from '../Avatar';
-import OfflineWithFeedback from '../OfflineWithFeedback';
-import CONST from '../../CONST';
-import * as StyleUtils from '../../styles/StyleUtils';
-import Icon from '../Icon';
-import * as Expensicons from '../Icon/Expensicons';
-import themeColors from '../../styles/themes/default';
import Tooltip from '../Tooltip';
-import UserDetailsTooltip from '../UserDetailsTooltip';
-
-function UserListItem({item, isFocused = false, showTooltip, onSelectRow, onDismissError = () => {}}) {
- const hasError = !_.isEmpty(item.errors);
-
- const avatar = (
-
- );
-
- const text = (
-
- {item.text}
-
- );
-
- const alternateText = (
-
- {item.alternateText}
-
- );
+import SubscriptAvatar from '../SubscriptAvatar';
+function UserListItem({item, isFocused = false, showTooltip}) {
return (
- onDismissError(item)}
- pendingAction={item.pendingAction}
- errors={item.errors}
- errorRowStyles={styles.ph5}
- >
- onSelectRow(item)}
- disabled={item.isDisabled}
- accessibilityLabel={item.text}
- accessibilityRole="checkbox"
- accessibilityState={{checked: item.isSelected}}
- hoverDimmingValue={1}
- hoverStyle={styles.hoveredComponentBG}
- dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
- >
-
-
+ {Boolean(item.icons) && (
+
+ )}
+
+
+
+ {item.text}
+
+
+ {Boolean(item.alternateText) && (
+
- {item.isSelected && (
-
- )}
-
-
- {Boolean(item.avatar) &&
- (showTooltip ? (
-
- {avatar}
-
- ) : (
- avatar
- ))}
-
- {showTooltip ? {text} : text}
- {Boolean(item.alternateText) && (showTooltip ? {alternateText} : alternateText)}
-
- {Boolean(item.rightElement) && item.rightElement}
-
-
+ {item.alternateText}
+
+
+ )}
+
+ {Boolean(item.rightElement) && item.rightElement}
+ >
);
}
diff --git a/src/components/SelectionList/selectionListPropTypes.js b/src/components/SelectionList/selectionListPropTypes.js
index 0a3c1efdf6a3..96c2f63eb09a 100644
--- a/src/components/SelectionList/selectionListPropTypes.js
+++ b/src/components/SelectionList/selectionListPropTypes.js
@@ -2,7 +2,29 @@ import PropTypes from 'prop-types';
import _ from 'underscore';
import CONST from '../../CONST';
+const commonListItemPropTypes = {
+ /** Whether this item is focused (for arrow key controls) */
+ isFocused: PropTypes.bool,
+
+ /** Whether this item is disabled */
+ isDisabled: PropTypes.bool,
+
+ /** Whether this item should show Tooltip */
+ showTooltip: PropTypes.bool.isRequired,
+
+ /** Whether to use the Checkbox (multiple selection) instead of the Checkmark (single selection) */
+ canSelectMultiple: PropTypes.bool,
+
+ /** Callback to fire when the item is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+
+ /** Callback to fire when an error is dismissed */
+ onDismissError: PropTypes.func,
+};
+
const userListItemPropTypes = {
+ ...commonListItemPropTypes,
+
/** The section list item */
item: PropTypes.shape({
/** Text to display */
@@ -29,12 +51,14 @@ const userListItemPropTypes = {
/** Element to show on the right side of the item */
rightElement: PropTypes.element,
- /** Avatar for the user */
- avatar: PropTypes.shape({
- source: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
- name: PropTypes.string,
- type: PropTypes.string,
- }),
+ /** Icons for the user (can be multiple if it's a Workspace) */
+ icons: PropTypes.arrayOf(
+ PropTypes.shape({
+ source: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
+ name: PropTypes.string,
+ type: PropTypes.string,
+ }),
+ ),
/** Errors that this user may contain */
errors: PropTypes.objectOf(PropTypes.string),
@@ -42,21 +66,11 @@ const userListItemPropTypes = {
/** The type of action that's pending */
pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)),
}).isRequired,
-
- /** Whether this item is focused (for arrow key controls) */
- isFocused: PropTypes.bool,
-
- /** Whether this item should show Tooltip */
- showTooltip: PropTypes.bool.isRequired,
-
- /** Callback to fire when the item is pressed */
- onSelectRow: PropTypes.func.isRequired,
-
- /** Callback to fire when an error is dismissed */
- onDismissError: PropTypes.func,
};
const radioListItemPropTypes = {
+ ...commonListItemPropTypes,
+
/** The section list item */
item: PropTypes.shape({
/** Text to display */
@@ -71,15 +85,11 @@ const radioListItemPropTypes = {
/** Whether this option is selected */
isSelected: PropTypes.bool,
}).isRequired,
+};
- /** Whether this item is focused (for arrow key controls) */
- isFocused: PropTypes.bool,
-
- /** Whether this item is disabled */
- isDisabled: PropTypes.bool,
-
- /** Callback to fire when the item is pressed */
- onSelectRow: PropTypes.func.isRequired,
+const baseListItemPropTypes = {
+ ...commonListItemPropTypes,
+ item: PropTypes.oneOfType([PropTypes.shape(userListItemPropTypes.item), PropTypes.shape(radioListItemPropTypes.item)]),
};
const propTypes = {
@@ -156,6 +166,9 @@ const propTypes = {
/** Whether to show the default confirm button */
showConfirmButton: PropTypes.bool,
+
+ /** Custom content to display in the footer */
+ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
};
-export {propTypes, radioListItemPropTypes, userListItemPropTypes};
+export {propTypes, baseListItemPropTypes, radioListItemPropTypes, userListItemPropTypes};
diff --git a/src/components/SubscriptAvatar.js b/src/components/SubscriptAvatar.js
index 81864d6e5af2..4102ae5ec043 100644
--- a/src/components/SubscriptAvatar.js
+++ b/src/components/SubscriptAvatar.js
@@ -26,6 +26,9 @@ const propTypes = {
/** Removes margin from around the avatar, used for the chat view */
noMargin: PropTypes.bool,
+
+ /** Whether to show the tooltip */
+ showTooltip: PropTypes.bool,
};
const defaultProps = {
@@ -34,42 +37,46 @@ const defaultProps = {
mainAvatar: {},
secondaryAvatar: {},
noMargin: false,
+ showTooltip: true,
};
-function SubscriptAvatar(props) {
- const isSmall = props.size === CONST.AVATAR_SIZE.SMALL;
- const subscriptStyle = props.size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript;
+function SubscriptAvatar({size, backgroundColor, mainAvatar, secondaryAvatar, noMargin, showTooltip}) {
+ const isSmall = size === CONST.AVATAR_SIZE.SMALL;
+ const subscriptStyle = size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript;
const containerStyle = isSmall ? styles.emptyAvatarSmall : styles.emptyAvatar;
// Default the margin style to what is normal for small or normal sized avatars
let marginStyle = isSmall ? styles.emptyAvatarMarginSmall : styles.emptyAvatarMargin;
// Some views like the chat view require that there be no margins
- if (props.noMargin) {
+ if (noMargin) {
marginStyle = {};
}
+
return (
diff --git a/src/components/TaskHeaderActionButton.js b/src/components/TaskHeaderActionButton.js
index b22b5c92bf70..a16f415f0fd7 100644
--- a/src/components/TaskHeaderActionButton.js
+++ b/src/components/TaskHeaderActionButton.js
@@ -34,7 +34,7 @@ function TaskHeaderActionButton(props) {