diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4f78ee6b69bf..c106a25c8ab1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,5 @@ # Every PR gets a review from an internal Expensify engineer * @Expensify/pullerbear + +# Every PR that touches redirects gets reviewed by ring0 +docs/redirects.csv @Expensify/infra \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/Standard.md b/.github/ISSUE_TEMPLATE/Standard.md index 5e0e3633f3bc..5c96d8736bcd 100644 --- a/.github/ISSUE_TEMPLATE/Standard.md +++ b/.github/ISSUE_TEMPLATE/Standard.md @@ -42,45 +42,8 @@ Which of our officially supported platforms is this issue occurring on? - [ ] MacOS: Desktop ## Screenshots/Videos -
-Android: Native - - -
- -
-Android: mWeb Chrome - - - -
- -
-iOS: Native - - - -
- -
-iOS: mWeb Safari - - - -
- -
-MacOS: Chrome / Safari - - - -
- -
-MacOS: Desktop - - +Add any screenshot/video evidence
diff --git a/.github/scripts/createHelpRedirects.sh b/.github/scripts/createHelpRedirects.sh new file mode 100755 index 000000000000..04f55e19b4fb --- /dev/null +++ b/.github/scripts/createHelpRedirects.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# +# Adds new routes to the Cloudflare Bulk Redirects list for communityDot to helpDot +# pages. Does some basic sanity checking. + +set -e + +source scripts/shellUtils.sh + +info "Adding any new redirects from communityDot to helpDot" + +declare -r LIST_ID="20eb13215038446a98fd69ccf6d1026d" +declare -r ZONE_ID="$CLOUDFLARE_ACCOUNT_ID" +declare -r REDIRECTS_FILE="docs/redirects.csv" + +function checkCloudflareResult { + RESULTS=$1 + RESULT_MESSAGE=$(echo "$RESULTS" | jq .success) + + if ! [[ "$RESULT_MESSAGE" == "true" ]]; then + ERROR_MESSAGE=$(echo "$RESULTS" | jq .errors) + error "Error calling Cloudfalre API: $ERROR_MESSAGE" + exit 1 + fi +} + +declare -a ITEMS_TO_ADD + +while read -r line; do + # Split each line of the file into a source and destination so we can sanity check + # and compare against the current list. + read -r -a LINE_PARTS < <(echo "$line" | tr ',' ' ') + SOURCE_URL=${LINE_PARTS[0]} + DEST_URL=${LINE_PARTS[1]} + + # Make sure the format of the line is as execpted. + if [[ "${#LINE_PARTS[@]}" -gt 2 ]]; then + error "Found a line with more than one comma: $line" + exit 1 + fi + + # Basic sanity checking to make sure that the source and destination are in expected + # subdomains. + if ! [[ $SOURCE_URL =~ ^https://community\.expensify\.com ]]; then + error "Found source URL that is not a community URL: $SOURCE_URL" + exit 1 + fi + + if ! [[ $DEST_URL =~ ^https://help\.expensify\.com ]]; then + error "Found destination URL that is not a help URL: $DEST_URL" + exit 1 + fi + + info "Source: $SOURCE_URL and destination: $DEST_URL appear to be formatted correctly." + + ITEMS_TO_ADD+=("$line") + +# This line skips the first line in the csv because the first line is a header row. +done <<< "$(tail +2 $REDIRECTS_FILE)" + +# Sanity check that we should actually be running this and we aren't about to delete +# every single redirect. +if [[ "${#ITEMS_TO_ADD[@]}" -lt 1 ]]; then + error "No items found to add, why are we running?" + exit 1 +fi + +# This block builds a single JSON object with all of our updates so we can +# reduce the number of API calls we make. You cannot add any logging or anything +# that prints to std out to this block or it will break. We capture all of the std out +# from this loop and pass it to jq to build the json object. Any non-json will break the +# jq call at the end. +PUT_JSON=$(for new in "${ITEMS_TO_ADD[@]}"; do + read -r -a LINE_PARTS < <(echo "$new" | tr ',' ' ') + SOURCE_URL=${LINE_PARTS[0]} + DEST_URL=${LINE_PARTS[1]} + + # We strip the prefix here so that the rule will match both http and https. Since vanilla will eventially be removed, + # we need to catch both because we will not have the http > https redirect done by vanilla anymore. + NO_PREFIX_SOURCE_URL=${SOURCE_URL/https:\/\//} + jq -n --arg source "$NO_PREFIX_SOURCE_URL" --arg dest "$DEST_URL" '{"redirect": {source_url: $source, target_url: $dest}}' +done | jq -n '. |= [inputs]') + +info "Adding redirects for $PUT_JSON" + +# We use PUT here instead of POST so that we replace the entire list in place. This has many benefits: +# 1. We don't have to check if items are already in the list, allowing this script to run faster +# 2. We can support deleting redirects this way by simply removing them from the list +# 3. We can support updating redirects this way, in the case of typos or moved destinations. +# +# Additionally this API call is async, so after we finish it, we must poll to wait for it to finish to +# to know that it was actually completed. +PUT_RESULT=$(curl -s --request PUT --url "https://api.cloudflare.com/client/v4/accounts/$ZONE_ID/rules/lists/$LIST_ID/items" \ + --header 'Content-Type: application/json' \ + --header "Authorization: Bearer $CLOUDFLARE_LIST_TOKEN" \ + --data "$PUT_JSON") + +checkCloudflareResult "$PUT_RESULT" +OPERATION_ID=$(echo "$PUT_RESULT" | jq -r .result.operation_id) + +DONE=false + +# Poll for completition +while [[ $DONE == false ]]; do + CHECK_RESULT=$(curl -s --request GET --url "https://api.cloudflare.com/client/v4/accounts/$ZONE_ID/rules/lists/bulk_operations/$OPERATION_ID" \ + --header 'Content-Type: application/json' \ + --header "Authorization: Bearer $CLOUDFLARE_LIST_TOKEN") + checkCloudflareResult "$CHECK_RESULT" + + STATUS=$(echo "$CHECK_RESULT" | jq -r .result.status) + + # Exit on completed or failed, other options are pending or running, in both cases + # we want to keep polling. + if [[ $STATUS == "completed" ]]; then + DONE=true + fi + + if [[ $STATUS == "failed" ]]; then + ERROR_MESSAGE=$(echo "$CHECK_RESULT" | jq .result.error) + error "List update failed with error: $ERROR_MESSAGE" + exit 1 + fi +done + +success "Updated lists successfully" diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index e6da6fff1446..43f3c64554bc 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -41,6 +41,7 @@ jobs: token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Set up git for OSBotify + id: setupGitForOSBotify uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} @@ -119,7 +120,7 @@ jobs: **Important:** There may be conflicts that GitHub is not able to detect, so please _carefully_ review this pull request before approving." gh pr edit --add-assignee "${{ github.actor }},${{ steps.getCPMergeCommit.outputs.MERGE_ACTOR }}" env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} - name: "Announces a CP failure in the #announce Slack room" uses: 8398a7/action-slack@v3 diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml index 4fe6249edacc..f8b68786aaab 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -34,13 +34,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. @@ -51,14 +51,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. diff --git a/.github/workflows/updateHelpDotRedirects.yml b/.github/workflows/updateHelpDotRedirects.yml new file mode 100644 index 000000000000..531b8a3812fd --- /dev/null +++ b/.github/workflows/updateHelpDotRedirects.yml @@ -0,0 +1,31 @@ +name: Update Redirects to ExpensifyHelp + +on: + # Run on any push to main that has changes to the redirects file + push: + branches: + - main + paths: + - 'docs/redirects.csv' + + # Run on any manual trigger + workflow_dispatch: + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "redirects" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + + - name: Create help dot redirect + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_LIST_TOKEN: ${{ secrets.CLOUDFLARE_LIST_TOKEN }} + run: ./.github/scripts/createHelpRedirects.sh diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js index fe58f3c0a6d8..6717c1736f65 100644 --- a/.storybook/webpack.config.js +++ b/.storybook/webpack.config.js @@ -24,8 +24,7 @@ const custom = require('../config/webpack/webpack.common')({ module.exports = ({config}) => { config.resolve.alias = { 'react-native-config': 'react-web-config', - 'react-native$': '@expensify/react-native-web', - 'react-native-web': '@expensify/react-native-web', + 'react-native$': 'react-native-web', '@react-native-community/netinfo': path.resolve(__dirname, '../__mocks__/@react-native-community/netinfo.js'), '@react-navigation/native': path.resolve(__dirname, '../__mocks__/@react-navigation/native'), diff --git a/android/app/build.gradle b/android/app/build.gradle index 8da6fb1a9b76..c6caab090428 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -95,8 +95,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001039400 - versionName "1.3.94-0" + versionCode 1001039606 + versionName "1.3.96-6" } flavorDimensions "default" diff --git a/assets/css/fonts.css b/assets/css/fonts.css index 7834a0ebb861..078cec114c31 100644 --- a/assets/css/fonts.css +++ b/assets/css/fonts.css @@ -54,6 +54,11 @@ src: url('/fonts/ExpensifyNewKansas-MediumItalic.woff2') format('woff2'), url('/fonts/ExpensifyNewKansas-MediumItalic.woff') format('woff'); } +@font-face { + font-family: Windows Segoe UI Emoji; + src: url('/fonts/seguiemj.ttf'); +} + * { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/assets/emojis/common.js b/assets/emojis/common.ts similarity index 99% rename from assets/emojis/common.js rename to assets/emojis/common.ts index b7593b6e2960..cbefb21cf2d6 100644 --- a/assets/emojis/common.js +++ b/assets/emojis/common.ts @@ -1,12 +1,13 @@ -import Smiley from '../images/emoji.svg'; -import Flags from '../images/emojiCategoryIcons/flag.svg'; -import FoodAndDrink from '../images/emojiCategoryIcons/hamburger.svg'; -import Objects from '../images/emojiCategoryIcons/light-bulb.svg'; -import Symbols from '../images/emojiCategoryIcons/peace-sign.svg'; -import TravelAndPlaces from '../images/emojiCategoryIcons/plane.svg'; -import AnimalsAndNature from '../images/emojiCategoryIcons/plant.svg'; -import Activities from '../images/emojiCategoryIcons/soccer-ball.svg'; -import FrequentlyUsed from '../images/history.svg'; +import Smiley from '@assets/images/emoji.svg'; +import Flags from '@assets/images/emojiCategoryIcons/flag.svg'; +import FoodAndDrink from '@assets/images/emojiCategoryIcons/hamburger.svg'; +import Objects from '@assets/images/emojiCategoryIcons/light-bulb.svg'; +import Symbols from '@assets/images/emojiCategoryIcons/peace-sign.svg'; +import TravelAndPlaces from '@assets/images/emojiCategoryIcons/plane.svg'; +import AnimalsAndNature from '@assets/images/emojiCategoryIcons/plant.svg'; +import Activities from '@assets/images/emojiCategoryIcons/soccer-ball.svg'; +import FrequentlyUsed from '@assets/images/history.svg'; +import {HeaderEmoji, PickerEmojis} from './types'; const skinTones = [ { @@ -33,9 +34,9 @@ const skinTones = [ code: '🖐🏿', skinTone: 0, }, -]; +] as const; -const emojis = [ +const emojis: PickerEmojis = [ { header: true, icon: Smiley, @@ -7619,7 +7620,7 @@ const emojis = [ }, ]; -const categoryFrequentlyUsed = { +const categoryFrequentlyUsed: HeaderEmoji = { header: true, code: 'frequentlyUsed', icon: FrequentlyUsed, diff --git a/assets/emojis/en.js b/assets/emojis/en.ts similarity index 99% rename from assets/emojis/en.js rename to assets/emojis/en.ts index f32a91afe03c..0a1ca7611117 100644 --- a/assets/emojis/en.js +++ b/assets/emojis/en.ts @@ -1,4 +1,7 @@ -const enEmojis = { +import {EmojisList} from './types'; + +/* eslint-disable @typescript-eslint/naming-convention */ +const enEmojis: EmojisList = { '😀': { keywords: ['smile', 'happy', 'face', 'grin'], }, diff --git a/assets/emojis/es.js b/assets/emojis/es.ts similarity index 99% rename from assets/emojis/es.js rename to assets/emojis/es.ts index fda12f5f127c..46f825643859 100644 --- a/assets/emojis/es.js +++ b/assets/emojis/es.ts @@ -1,4 +1,7 @@ -const esEmojis = { +import {EmojisList} from './types'; + +/* eslint-disable @typescript-eslint/naming-convention */ +const esEmojis: EmojisList = { '😀': { name: 'sonriendo', keywords: ['cara', 'divertido', 'feliz', 'sonrisa', 'cara sonriendo'], diff --git a/assets/emojis/index.js b/assets/emojis/index.js deleted file mode 100644 index c8dab36f57d9..000000000000 --- a/assets/emojis/index.js +++ /dev/null @@ -1,41 +0,0 @@ -import _ from 'underscore'; -import emojis from './common'; -import enEmojis from './en'; -import esEmojis from './es'; - -const emojiNameTable = _.reduce( - emojis, - (prev, cur) => { - const newValue = prev; - if (!cur.header) { - newValue[cur.name] = cur; - } - return newValue; - }, - {}, -); - -const emojiCodeTableWithSkinTones = _.reduce( - emojis, - (prev, cur) => { - const newValue = prev; - if (!cur.header) { - newValue[cur.code] = cur; - } - if (cur.types) { - cur.types.forEach((type) => { - newValue[type] = cur; - }); - } - return newValue; - }, - {}, -); - -const localeEmojis = { - en: enEmojis, - es: esEmojis, -}; - -export {emojiNameTable, emojiCodeTableWithSkinTones, localeEmojis}; -export {skinTones, categoryFrequentlyUsed, default} from './common'; diff --git a/assets/emojis/index.ts b/assets/emojis/index.ts new file mode 100644 index 000000000000..aade4e557a64 --- /dev/null +++ b/assets/emojis/index.ts @@ -0,0 +1,35 @@ +import emojis from './common'; +import enEmojis from './en'; +import esEmojis from './es'; +import {Emoji} from './types'; + +type EmojiTable = Record; + +const emojiNameTable = emojis.reduce((prev, cur) => { + const newValue = prev; + if (!('header' in cur) && cur.name) { + newValue[cur.name] = cur; + } + return newValue; +}, {}); + +const emojiCodeTableWithSkinTones = emojis.reduce((prev, cur) => { + const newValue = prev; + if (!('header' in cur)) { + newValue[cur.code] = cur; + } + if ('types' in cur && cur.types) { + cur.types.forEach((type) => { + newValue[type] = cur; + }); + } + return newValue; +}, {}); + +const localeEmojis = { + en: enEmojis, + es: esEmojis, +} as const; + +export {emojiNameTable, emojiCodeTableWithSkinTones, localeEmojis}; +export {skinTones, categoryFrequentlyUsed, default} from './common'; diff --git a/assets/emojis/types.ts b/assets/emojis/types.ts new file mode 100644 index 000000000000..a42f44ed7fa7 --- /dev/null +++ b/assets/emojis/types.ts @@ -0,0 +1,19 @@ +import {SvgProps} from 'react-native-svg'; + +type Emoji = { + code: string; + name: string; + types?: string[]; +}; + +type HeaderEmoji = { + header: true; + icon: React.FC; + code: string; +}; + +type PickerEmojis = Array; + +type EmojisList = Record; + +export type {Emoji, HeaderEmoji, EmojisList, PickerEmojis}; diff --git a/assets/fonts/web/seguiemj.ttf b/assets/fonts/web/seguiemj.ttf new file mode 100644 index 000000000000..3a455801aa0c Binary files /dev/null and b/assets/fonts/web/seguiemj.ttf differ diff --git a/assets/images/bell.svg b/assets/images/bell.svg index 6ba600dc695b..5a6b411185a9 100644 --- a/assets/images/bell.svg +++ b/assets/images/bell.svg @@ -1,6 +1 @@ - - - - - + \ No newline at end of file diff --git a/assets/images/home-background--android.svg b/assets/images/home-background--android.svg index 507aecf04836..2b72b6ccabe9 100644 --- a/assets/images/home-background--android.svg +++ b/assets/images/home-background--android.svg @@ -1,6555 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/babel.config.js b/babel.config.js index 7de6926c850d..d8ad66917b82 100644 --- a/babel.config.js +++ b/babel.config.js @@ -17,16 +17,8 @@ const defaultPlugins = [ ]; const webpack = { - env: { - production: { - presets: defaultPresets, - plugins: [...defaultPlugins, 'transform-remove-console'], - }, - development: { - presets: defaultPresets, - plugins: defaultPlugins, - }, - }, + presets: defaultPresets, + plugins: defaultPlugins, }; const metro = { @@ -78,6 +70,11 @@ const metro = { }, ], ], + env: { + production: { + plugins: [['transform-remove-console', {exclude: ['error', 'warn']}]], + }, + }, }; /* @@ -102,11 +99,19 @@ if (process.env.CAPTURE_METRICS === 'true') { ]); } -module.exports = ({caller}) => { +module.exports = (api) => { + console.debug('babel.config.js'); + console.debug(' - api.version:', api.version); + console.debug(' - api.env:', api.env()); + console.debug(' - process.env.NODE_ENV:', process.env.NODE_ENV); + console.debug(' - process.env.BABEL_ENV:', process.env.BABEL_ENV); + // For `react-native` (iOS/Android) caller will be "metro" // For `webpack` (Web) caller will be "@babel-loader" // For jest, it will be babel-jest // For `storybook` there won't be any config at all so we must give default argument of an empty object - const runningIn = caller((args = {}) => args.name); + const runningIn = api.caller((args = {}) => args.name); + console.debug(' - running in: ', runningIn); + return ['metro', 'babel-jest'].includes(runningIn) ? metro : webpack; }; diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 8d423dbc4213..b66b4f67a3b6 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -13,7 +13,7 @@ const includeModules = [ 'react-native-animatable', 'react-native-reanimated', 'react-native-picker-select', - '@expensify/react-native-web', + 'react-native-web', 'react-native-webview', '@react-native-picker', 'react-native-modal', @@ -185,9 +185,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ resolve: { alias: { 'react-native-config': 'react-web-config', - 'react-native$': '@expensify/react-native-web', - 'react-native-web': '@expensify/react-native-web', - 'lottie-react-native': 'react-native-web-lottie', + 'react-native$': 'react-native-web', // Module alias for web & desktop // https://webpack.js.org/configuration/resolve/#resolvealias diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index 5c51f16ffc4d..6e02cae677bb 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -32,12 +32,9 @@ This project and everyone participating in it is governed by the Expensify [Code At this time, we are not hiring contractors in Crimea, North Korea, Russia, Iran, Cuba, or Syria. ## Slack channels -All contributors should be a member of **two** Slack channels: +All contributors should be a member of a shared Slack channel called [#expensify-open-source](https://expensify.slack.com/archives/C01GTK53T8Q) -- this channel is used to ask **general questions**, facilitate **discussions**, and make **feature requests**. -1. [#expensify-open-source](https://expensify.slack.com/archives/C01GTK53T8Q) -- used to ask **general questions**, facilitate **discussions**, and make **feature requests**. -2. [#expensify-bugs](https://expensify.slack.com/archives/C049HHMV9SM) -- used to discuss or report **bugs** specifically. - -Before requesting an invite to Slack please ensure your Upwork account is active, since we only pay via Upwork (see [below](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#payment-for-contributions)). To request an invite to these two Slack channels, email contributors@expensify.com with the subject `Slack Channel Invites`. We'll send you an invite! +Before requesting an invite to Slack please ensure your Upwork account is active, since we only pay via Upwork (see [below](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#payment-for-contributions)). To request an invite to Slack, email contributors@expensify.com with the subject `Slack Channel Invites`. We'll send you an invite! Note: Do not send direct messages to the Expensify team in Slack or Expensify Chat, they will not be able to respond. @@ -47,30 +44,21 @@ Note: if you are hired for an Upwork job and have any job-specific questions, pl If you've found a vulnerability, please email security@expensify.com with the subject `Vulnerability Report` instead of creating an issue. ## Payment for Contributions -We hire and pay external contributors via Upwork.com. If you'd like to be paid for contributing or reporting a bug, please create an Upwork account, apply for an available job in [GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22), and finally apply for the job in Upwork once your proposal gets selected in GitHub. Please make sure your Upwork profile is **fully verified** before applying, otherwise you run the risk of not being paid. If you think your compensation should be increased for a specific job, you can request a reevaluation by commenting in the Github issue where the Upwork job was posted. +We hire and pay external contributors via Upwork.com. If you'd like to be paid for contributing, please create an Upwork account, apply for an available job in [GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22), and finally apply for the job in Upwork once your proposal gets selected in GitHub. Please make sure your Upwork profile is **fully verified** before applying, otherwise you run the risk of not being paid. If you think your compensation should be increased for a specific job, you can request a reevaluation by commenting in the Github issue where the Upwork job was posted. -Payment for your contributions and bug reports will be made no less than 7 days after the pull request is deployed to production to allow for [regression](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions) testing. If you have not received payment after 8 days of the PR being deployed to production, and there are no [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions), please add a comment to the issue mentioning the BugZero team member (Look for the melvin-bot "Triggered auto assignment to... (`Bug`)" to see who this is). +Payment for your contributions will be made no less than 7 days after the pull request is deployed to production to allow for [regression](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions) testing. If you have not received payment after 8 days of the PR being deployed to production, and there are no [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions), please add a comment to the issue mentioning the BugZero team member (Look for the melvin-bot "Triggered auto assignment to... (`Bug`)" to see who this is). New contributors are limited to working on one job at a time, however experienced contributors may work on numerous jobs simultaneously. Please be aware that compensation for any support in solving an issue is provided **entirely at Expensify’s discretion**. Personal time or resources applied towards investigating a proposal **will not guarantee compensation**. Compensation is only guaranteed to those who **[propose a solution and get hired for that job](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#propose-a-solution-for-the-job)**. We understand there may be cases where a selected proposal may take inspiration from a previous proposal. Unfortunately, it’s not possible for us to evaluate every individual case and we have no process that can efficiently do so. Issues with higher rewards come with higher risk factors so try to keep things civil and make the best proposal you can. Once again, **any information provided may not necessarily lead to you getting hired for that issue or compensated in any way.** -**Important:** Payment amounts are variable, dependent on when your PR is merged and if there are any [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions). Your PR will be reviewed by a [Contributor+ (C+)](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md). team member and an internal engineer. All tests must pass and all code must pass lint checks before a merge. - -**Payment timelines** are based on the day and timestamp the contributor is assigned to the Github issue by an Expensify employee: -- Merged PR within 3 business days (72 hours) - 50% **bonus** -- Merged PR within 6 business days (144 hours) - 0% bonus -- Merged PR within 9 business days (216 hours) - 50% **penalty** -- No PR within 12 business days - **Contract terminated** - -We specify exact hours to make sure we can clearly decide what is eligible for the bonus given our team is global and contributors span across all the timezones. +**Important:** Payment amounts are variable, dependent on if there are any [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions). Your PR will be reviewed by a [Contributor+ (C+)](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) team member and an internal engineer. All tests must pass and all code must pass lint checks before a merge. ### Regressions If a PR causes a regression at any point within the regression period (starting when the code is merged and ending 168 hours (that's 7 days) after being deployed to production): - payments will be issued 7 days after all regressions are fixed (ie: deployed to production) - a 50% penalty will be applied to the Contributor and [Contributor+](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) for each regression on an issue -- the assigned Contributor and [Contributor+](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) are not eligible for the 50% urgency bonus The 168 hours (aka 7 days) will be measured by calculating the time between when the PR is merged, and when a bug is posted to the #expensify-bugs Slack channel. @@ -80,25 +68,6 @@ A job could be fixing a bug or working on a new feature. There are two ways you #### Finding a job that Expensify posted This is the most common scenario for contributors. The Expensify team posts new jobs to the Upwork job list [here](https://www.upwork.com/ab/jobs/search/?q=Expensify%20React%20Native&sort=recency&user_location_match=2) (you must be signed in to Upwork to view jobs). Each job in Upwork has a corresponding GitHub issue, which will include instructions to follow. You can also view all open jobs in the Expensify/App GH repository by searching for GH issues with the [`Help Wanted` label](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22). Lastly, you can follow the [@ExpensifyOSS](https://twitter.com/ExpensifyOSS) Twitter account to see a live feed of jobs that are posted. -#### Raising jobs and bugs -It’s possible that you found a new bug that we haven’t posted as a job to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to raise it and claim the bug bounty. If it's a valid bug that we choose to resolve by deploying it to production — either internally or via an external contributor — then we will compensate you $50 for identifying the bug (we do not compensate for reporting new feature requests). If the bug is fixed by a PR that is not associated with your bug report, then you will not be eligible for the corresponding compensation unless you can find the PR that fixed it and prove your bug report came first. -- Note: If you get assigned the job you proposed **and** you complete the job, this $50 for identifying the improvement is *in addition to* the reward you will be paid for completing the job. -- Note about proposed bugs: Expensify has the right not to pay the $50 reward if the suggested bug has already been reported. Following, if more than one contributor proposes the same bug, the contributor who posted it first in the [#expensify-bugs](https://expensify.slack.com/archives/C049HHMV9SM) Slack channel is the one who is eligible for the bonus. -- Note: whilst you may optionally propose a solution for that job on Slack, solutions are ultimately reviewed in GitHub. The onus is on you to propose the solution on GitHub, and/or ensure the issue creator will include a link to your proposal. - -Please follow these steps to propose a job or raise a bug: - -1. Check to ensure a GH issue does not already exist for this job in the [New Expensify Issue list](https://github.com/Expensify/App/issues). -2. Check to ensure the `Bug:` or `Feature Request:` was not already posted in Slack (specifically the #expensify-bugs or #expensify-open-source [Slack channels](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#slack-channels)). Use your best judgement by searching for similar titles, words and issue descriptions. -3. If your bug or new feature matches with an existing issue, please comment on that Slack thread or GitHub issue with your findings if you think it will help solve the issue. -4. If there is no existing GitHub issue or Upwork job, check if the issue is happening on prod (as opposed to only happening on dev) -5. If the issue is just in dev then it means it's a new issue and has not been deployed to production. In this case, you should try to find the offending PR and comment in the issue tied to the PR and ask the assigned users to add the `DeployBlockerCash` label. If you can't find it, follow the reporting instructions in the next item, but note that the issue is a regression only found in dev and not in prod. -6. If the issue happens in main, staging, or production then report the issue(s) in the #expensify-bugs Slack channel, using the report bug workflow. You can do this by clicking 'Workflow > report Bug', or typing `/Report bug`. View [this guide](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_CREATE_A_PLAN.md) for help creating a plan when proposing a feature request. Please verify the bug's presence on **every** platform mentioned in the bug report template, and confirm this with a screen recording.. - - **Important note/reminder**: never share any information pertaining to a customer of Expensify when describing the bug. This includes, and is not limited to, a customer's name, email, and contact information. -7. The Applause team will review your job proposal in the appropriate slack channel. If you've provided a quality proposal that we choose to implement, a GitHub issue will be created and your Slack handle will be included in the original post after `Issue reported by:` -8. If an external contributor other than yourself is hired to work on the issue, you will also be hired for the same job in Upwork to receive your payout. No additional work is required. If the issue is fixed internally, a dedicated job will be created to hire and pay you after the issue is fixed. -9. Payment will be made 7 days after code is deployed to production if there are no regressions. If a regression is discovered, payment will be issued 7 days after all regressions are fixed. - >**Note:** Our problem solving approach at Expensify is to focus on high value problems and avoid small optimizations with results that are difficult to measure. We also prefer to identify and solve problems at their root. Given that, please ensure all proposed jobs fix a specific problem in a measurable way with evidence so they are easy to evaluate. Here's an example of a good problem/solution: > >**Problem:** The app start up time has regressed because we introduced "New Feature" in PR #12345 and is now 1042ms slower because `SomeComponent` is re-rendering 42 times. @@ -179,7 +148,7 @@ Additionally if you want to discuss an idea with the open source community witho - If you have made a change to your pull request and are ready for another review, leave a comment that says "Updated" on the pull request itself. - Please keep the conversation in GitHub, and do not ping individual reviewers in Slack or Upwork to get their attention. - Pull Request reviews can sometimes take a few days. If your pull request has not been addressed after four days please let us know via the #expensify-open-source Slack channel. -- On occasion, our engineers will need to focus on a feature release and choose to place a hold on the review of your PR. Depending on the hold length, our team will decide if a bonus will be applied to the job. +- On occasion, our engineers will need to focus on a feature release and choose to place a hold on the review of your PR. #### Important note about JavaScript Style - Read our official [JavaScript and React style guide](https://github.com/Expensify/App/blob/main/contributingGuides/STYLE.md). Please refer to our Style Guide before asking for a review. diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index 1fb67483daca..ffec5f20254c 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -53,17 +53,27 @@ The phone number can be formatted in different ways. ### Native Keyboards -We should always set people up for success on native platforms by enabling the best keyboard for the type of input we’re asking them to provide. See [keyboardType](https://reactnative.dev/docs/0.64/textinput#keyboardtype) in the React Native documentation. +We should always set people up for success on native platforms by enabling the best keyboard for the type of input we’re asking them to provide. See [inputMode](https://reactnative.dev/docs/textinput#inputmode) in the React Native documentation. -We have a couple of keyboard types [defined](https://github.com/Expensify/App/blob/572caa9e7cf32a2d64fe0e93d171bb05a1dfb217/src/CONST.js#L357-L360) and should be used like so: +We have a list of input modes [defined](https://github.com/Expensify/App/blob/9418b870515102631ea2156b5ea253ee05a98ff1/src/CONST.js#L765-L774) and should be used like so: ```jsx ``` +We also have [keyboardType](https://github.com/Expensify/App/blob/9418b870515102631ea2156b5ea253ee05a98ff1/src/CONST.js#L760-L763) and should be used for specific use cases when there is no `inputMode` equivalent of the value exist. and should be used like so: + +```jsx + +``` + + ### Autofill Behavior Forms should autofill information whenever possible i.e. they should work with browsers and password managers auto complete features. diff --git a/docs/Expensify-Card-revenue share for ExpensifyApproved!-partners.md b/docs/Expensify-Card-revenue share for ExpensifyApproved!-partners.md deleted file mode 100644 index f9d18da76ef6..000000000000 --- a/docs/Expensify-Card-revenue share for ExpensifyApproved!-partners.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: Expensify Card revenue share for ExpensifyApproved! partners -description: Earn money when your clients adopt the Expensify Card ---- - - -# About -Start making more with us! We're thrilled to announce a new incentive for our US-based ExpensifyApproved! partner accountants. You can now earn additional income for your firm every time your client uses their Expensify Card. We're offering 0.5% of the total Expensify Card spend of your clients in cashback returned to your firm. The more your clients spend, the more cashback your firm receives!
-
This program is currently only available to US-based ExpensifyApproved! partner accountants. - -# How-to -To benefit from this program, complete the following steps -1. Ensure that you are listed as the Primary Contact under your client's domain in Expensify. If you're not currently the Primary Contact for your client, you or your client can follow the instructions outlined in [our help article](https://community.expensify.com/discussion/5749/how-to-add-and-remove-domain-admins#:~:text=Domain%20Admins%20have%20total%20control,a%20member%20of%20the%20domain.) to assign you this role. -2. Add a Business Bank Account to the Primary Contact account. Follow the instructions in [our help article](https://community.expensify.com/discussion/4641/how-to-add-a-deposit-only-bank-account-both-personal-and-business) to get a Business Bank Account added. - -# FAQ -- What if my firm is not permitted to accept revenue share from our clients?
-
We understand that different firms may have different policies. If your firm is unable to accept this revenue share, you can pass the revenue share back to your client to give them an additional 0.5% of cash back using your own internal payment tools.

-How will I know which clients to pay back?
-
Every month you will receive an automated message via new.expensify.com and email providing a breakdown of revenue shared generated per client.

-- What if my firm does not wish to participate in the program?
-
Please reach out to your assigned partner manager at new.expensify.com to inform them you would not like to accept the revenue share nor do you want to pass the revenue share to your clients. diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html index 0183da296bff..d18ca2199e33 100644 --- a/docs/_includes/footer.html +++ b/docs/_includes/footer.html @@ -25,9 +25,6 @@

Features

  • Invoicing
  • -
  • - CPA Card -
  • Payroll
  • diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index f085379357c4..46434787d6df 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -35,31 +35,8 @@ html { } table { - margin-bottom: 20px; border-spacing: 0; border-collapse: collapse; - border-radius: 8px; - - // Box shadow is used here because border-radius and border-collapse don't work together. It leads to double borders. - // https://stackoverflow.com/questions/628301/the-border-radius-property-and-border-collapsecollapse-dont-mix-how-can-i-use - border-style: hidden; - box-shadow: 0 0 0 1px $color-green-borders; -} - -th:first-child { - border-top-left-radius: 8px; -} - -th:last-child { - border-top-right-radius: 8px; -} - -tr:last-child > td:first-child { - border-bottom-left-radius: 8px; -} - -tr:last-child > td:last-child { - border-bottom-right-radius: 8px; } caption, @@ -68,13 +45,6 @@ td { text-align: left; font-weight: 400; vertical-align: middle; - padding: 6px 13px; - border: 1px solid $color-green-borders; -} - -thead tr th { - font-weight: bold; - background-color: $color-green-highlightBG; } q, @@ -395,6 +365,43 @@ button { } } + table { + margin-bottom: 20px; + border-radius: 8px; + + // Box shadow is used here because border-radius and border-collapse don't work together. It leads to double borders. + // https://stackoverflow.com/questions/628301/the-border-radius-property-and-border-collapsecollapse-dont-mix-how-can-i-use + border-style: hidden; + box-shadow: 0 0 0 1px $color-green-borders; + } + + th:first-child { + border-top-left-radius: 8px; + } + + th:last-child { + border-top-right-radius: 8px; + } + + tr:last-child > td:first-child { + border-bottom-left-radius: 8px; + } + + tr:last-child > td:last-child { + border-bottom-right-radius: 8px; + } + + th, + td { + padding: 6px 13px; + border: 1px solid $color-green-borders; + } + + thead tr th { + font-weight: bold; + background-color: $color-green-highlightBG; + } + .img-wrap { display: flex; justify-content: space-around; diff --git a/docs/articles/expensify-classic/account-settings/Copilot.md b/docs/articles/expensify-classic/account-settings/Copilot.md new file mode 100644 index 000000000000..4fac402b7ced --- /dev/null +++ b/docs/articles/expensify-classic/account-settings/Copilot.md @@ -0,0 +1,69 @@ +--- +title: Copilot +description: Safely delegate tasks without sharing login information. +--- + +# About +The Copilot feature allows you to safely delegate tasks without sharing login information. Your chosen user can access your account through their own Expensify account, with customizable permissions to manage expenses, create reports, and more. This can even be extended to users outside your policy or domain. + +# How-to +# How to add a Copilot +1. Log into the Expensify desktop website. +2. Navigate to *Settings > Account > Account Details > _Copilot: Delegated Access_*. +3. Enter the email address or phone number of your Copilot and select whether you want to give them Full Access or the ability to Submit Only. + - *Full Access Copilot*: Your Copilot will have full access to your account. Nearly every action you can do and everything you can see in your account will also be available to your Copilot. They *will not* have the ability to add or remove other Copilots from your account. + - *Submit Only Copilot*: Your Copilot will have the same limitations as a Full Access Copilot, with the added restriction of not being able to approve reports on your behalf. +4. Click Invite Copilot. + +If your Copilot already has an Expensify account, they will get an email notifying them that they can now access your account from within their account as well. +If they do not already have an Expensify account, they will be provided with a link to create one. Once they have created their Expensify account, they will be able to access your account from within their own account. + +# How to use Copilot +A designated copilot can access another account via the Expensify website or the mobile app. + +## How to switch to Copilot mode (on the Expensify website): +1. Click your profile icon in the upper left side of the page. +2. In the “Copilot Access” section of the dropdown, choose the account you wish to access. +3. When you Copilot into someone else’s account, the Expensify header will change color and an airplane icon will appear. +4. You can return to your own account at any time by accessing the user menu and choosing “Return to your account”. + +## How to switch to Copilot Mode (on the mobile app): +1. Tap on the menu icon on the top left-hand side of the screen, then tap your profile icon. +2. Tap “Switch to Copilot Mode”, then choose the account you wish to access. +3. You can return to your own account at any time by accessing the user menu and choosing “Return to your account”. + +# How to remove a Copilot +If you ever need to remove a Copilot, you can do so by following the below steps: +1. Log into the Expensify desktop website +2. Navigate to *Settings > Your Account > Account Details > _Copilot: Delegated Access_* +3. Click the red X next to the Copilot you'd like to remove + + +# Deep Dive +## Copilot Permissions +A Copilot can do the following actions in your account: +- Prepare expenses on your behalf +- Approve and reimburse others' expenses on your behalf (Note: this applies only to **Full Access** Copilots) +- View and make changes to your account/domain/policy settings +- View all expenses you can see within your own account + +## Copilot restrictions +A Copilot cannot do the following actions in your account: +- Change or reset your password +- Add/remove other Copilots + +## Forwarding receipts to receipts@expensify.com as a Copilot +To ensure a receipt is routed to the Expensify account in which you are a copilot rather than your own you’ll need to do the following: +1. Forward the email to receipts@expensify.com +2. Put the email of the account in which you are a copilot in the subject line +3. Send + + +# FAQ +## Can a Copilot's Secondary Login be used to forward receipts? +Yes! A Copilot can use any of the email addresses tied to their account to forward receipts into the account of the person they're assisting. + +## I'm in Copilot mode for an account; Can I add another Copilot to that account on their behalf? +No, only the original account holder can add another Copilot to the account. +## Is there a restriction on the number of Copilots I can have or the number of users for whom I can act as a Copilot? +There is no limit! You can have as many Copilots as you like, and you can be a Copilot for as many users as you need. diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Cards.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Cards.md new file mode 100644 index 000000000000..71edcdeba00d --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Cards.md @@ -0,0 +1,5 @@ +--- +title: Personal Cards +description: Connect your credit card directly to Expensify to easily track your personal finances. +--- +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md deleted file mode 100644 index f89729b69586..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Personal Credit Cards -description: Personal Credit Cards ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Brex.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Brex.md deleted file mode 100644 index a060e37146a5..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Brex.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Brex -description: Brex ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md index 6debce6240ff..fc1e83701caf 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md @@ -1,5 +1,101 @@ --- -title: CSV Import -description: CSV Import +title: Import and assign company cards from CSV file +description: uploading a CSV file containing your company card transactions --- -## Resource Coming Soon! + +# Overview +Expensify offers a convenient CSV import feature for managing company card expenses when direct connections or commercial card feeds aren't available. This feature allows you to upload a CSV file containing your company card transactions and assign them to cardholders within your Expensify domain. +This feature is available on Group Workspaces and requires Domain Admin access. + +# How to import company cards via CSV +1. Download a CSV of transactions from your bank by logging into their website and finding the relevant statement. +2. Format the CSV for upload using [this template](https://s3-us-west-1.amazonaws.com/concierge-responses-expensify-com/uploads%2F1594908368712-Best+Example+CSV+for+Domains.csv) as a guide. +- At a minimum, your file must include the following columns: + - **Card Number** - each number in this column should display at least the last four digits, and you can obscure up to 12 characters +(e.g., 543212XXXXXX12334). + - **Posted Date** - use the YYYY-MM-DD format in this column (and any other date column in your spreadsheet). + - **Merchant** - the name of the individual or business that provided goods or services for the transaction. This is a free-text field. + - **Posted Amount** - use the number format in this column, and indicate negative amounts with parentheses (e.g., (335.98) for -$335.98). + - **Posted Currency** - use currency codes (e.g., USD, GBP, EUR) to indicate the currency of the posted transactions. +- You can also add mapping for Categories and Tags, but those parameters are optional. +3. Log into Expensify on your web browser. +4. Head to Settings > Domains > Domain Name > Company Cards +5. Click Manage/Import CSV +6. Create a Company Card Layout Name for your spreadsheet +7. Click Upload CSV +8. Review the mapping of your spreadsheet to ensure that the Card Number, Date, Merchant, Amount, and Currency match your data. +9. Double-check the Output Preview for any errors and, if needed, refer to the common error solutions listed in the FAQ below. +10. Once the mapping is correct, click Submit Spreadsheet to complete the import. +11. After submitting the spreadsheet, click I'll wait a minute. Then, wait about 1-2 minutes for the import to process. The domain page will refresh once the upload is complete. + +# How to assign new cards +If you're assigning cards via CSV upload for the first time: +1. Head to **Settings > Domains > Domain Name > Company Cards** +2. Find the new CSV feed in the drop-down list underneath **Imported Cards** +3. Click **Assign New Cards** +4. Under **Assign a Card**, enter the relevant info +5. Click **Assign** +From there, transactions will be imported to the cardholder's account, where they can add receipts, code the expenses, and submit them for review and approval. + +# How to upload new expenses for existing assigned cards +There's no need to create a new upload layout for subsequent CSV uploads. Instead, add new expenses to the existing CSV: +1. Head to **Settings > Domains > Domain Name > Company Cards** +2. Click **Manage/Import CSV** +3. Select the saved layout from the drop-down list +4. Click **Upload CSV** +5. After uploading the more recent CSV, click **Update All Cards** to retrieve the new expenses for the assigned cards. + +# Deep dive +If the CSV upload isn't formatted correctly, it will cause issues when you try to import or assign cards. Let's go over some common issues and how to fix them. + +## Error: "Attribute value mapping is missing" +If you encounter an error that says "Attribute-value mapping is missing," the spreadsheet likely lacks critical details like Card Number, Date, Merchant, Amount, or Currency. To resolve: +1. Click the **X** at the top of the page to close the mapping window +2. Confirm what's missing from the spreadsheet +3. Add a new column to your spreadsheet and add the missing detail +4. Upload the revised spreadsheet by clicking **Manage Spreadsheet** +5. Enter a **Company Card Layout Name** for the contents of your spreadsheet +6. Click **Upload CSV** + +## Error: "We've detected an error while processing your spreadsheet feed" +This error usually occurs when there's an upload issue. +To troubleshoot this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created. +3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported. +4. If the layout isn't listed, create a new **Company Card Layout Name** and upload the spreadsheet again. + +## Error: "An unexpected error occurred, and we could not retrieve the list of cards" +This error occurs when there's an issue uploading the spreadsheet or the upload fails. +To troubleshoot this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created. +3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported. +4. If the layout isn't listed, create a new **Company Card Layout Name** and upload the spreadsheet again. + + +## I added a new parameter to an existing spreadsheet, but the data isn't showing in Expensify after the upload completes. What's going on? +If you added a new card to an existing spreadsheet and imported it via a saved layout, but it isn't showing up for assignment, this suggests that the modification may have caused an issue. +The next step in troubleshooting this issue is to compare the number of rows on the revised spreadsheet to the Output Preview to ensure the row count matches the revised spreadsheet. +To check this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. Select your saved layout in the dropdown list +3. Click **Upload CSV** and select the revised spreadsheet +4. Compare the Output Preview row count to your revised spreadsheet to ensure they match + + +If they don't match, you'll need to revise the spreadsheet by following the CSV formatting guidelines in step 2 of "How to import company cards via CSV" above. +Once you do that, save the revised spreadsheet with a new layout name. +Then, try to upload the revised spreadsheet again: + +1. Click **Upload CSV** +2. Upload the revised file +3. Check the row count again on the Output Preview to confirm it matches the spreadsheet +4. Click **Submit Spreadsheet** + +# FAQ +## Why can't I see my CSV transactions immediately after uploading them? +Don't worry! You'll typically need to wait 1-2 minutes after clicking **I understand, I'll wait!** + +## I'm trying to import a credit. Why isn't it uploading? +Negative expenses shouldn't include a minus sign. Instead, they should just be wrapped in parentheses. For example, to indicate "-335.98," you'll want to make sure it's formatted as "(335.98)." diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md similarity index 99% rename from docs/articles/expensify-classic/billing-and-subscriptions/Overview.md rename to docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md index b835db54cbf2..30a507a1f9df 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md @@ -1,5 +1,5 @@ --- -title: Billing in Expensify +title: Billing Overview description: An overview of how billing works in Expensify. --- # Overview diff --git a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md index ae367d25891e..7f3d83af1e6e 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md +++ b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md @@ -11,12 +11,14 @@ Every expense has an Attendees field and will list the expense creator’s name ## How to Add Additional Attendees to an Expense * Go to the attendees field * Search for the names of the attendees - * The default list will be of internal attendees belonging to your workspace and domain. + * The default list will be of internal attendees belonging to your workspace and domain * External attendees are not part of your workspace or domain, so you will need to enter their name or email * Select the attendees you would like to add * Save the expense -* Once added, the list of attendees for each expense will be visible on the expense line. -* An amount per employee expense will also be displayed on the report for easy viewing +* Once added, the list of attendees for each expense will be visible on the expense line +* An amount per employee expense will also be displayed on the report for easy viewing + +![image of an expense with attendee tracking]({{site.url}}/assets/images/attendee-tracking.png){:width="100%"} # FAQ diff --git a/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md b/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md index f30dde9efc3d..42a8a914e5bc 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md +++ b/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md @@ -1,5 +1,73 @@ --- title: The Expenses Page -description: The Expenses Page +description: Details on Expenses Page filters --- -## Resource Coming Soon! +# Overview + +The Expenses page allows you to see all of your personal expenses. If you are an admin, you can view all submitter’s expenses on the Expensify page. The Expenses page can be filtered in several ways to give you spending visibility, find expenses to submit and export to a spreadsheet (CSV). + +## Expense filters +Here are the available filters you can use on the Expenses Page: + +- **Date Range:** Find expenses within a specific time frame. +- **Merchant Name:** Search for expenses from a particular merchant. (Partial search terms also work if you need clarification on the exact name match.) +- **Workspace:** Locate specific Group/Individual Workspace expenses. +- **Categories:** Group expenses by category or identify those without a category. +- **Tags:** Filter expenses with specific tags. +- **Submitters:** Narrow expenses by submitter (employee or vendor). +- **Personal Expenses:** Find all expenses yet to be included in a report. A Workspace admin can see these expenses once they are on a Processing, Approved, or Reimbursed report. +- **Open:** Display expenses on reports that still need to be submitted (not submitted). +- **Processing, Approved, Reimbursed:** See expenses on reports at various stages – processing, approved, or reimbursed. +- **Closed:** View expenses on closed reports (not submitted for approval). + +Here's how to make the most of these filters: + +1. Log into your web account +2. Go to the **Expenses** page +3. At the top of the page, click on **Show Filters** +4. Adjust the filters to match your specific needs + +Note, you might notice that not all expense filters are always visible. They adapt based on the data you're currently filtering and persist from the last time you logged in. For instance, you won't see the deleted filter if there are no **Deleted** expenses to filter out. + +If you are not seeing what you expected, you may have too many filters applied. Click **Reset** at the top to clear your filters. + + +# How to add an expense to a report from the Expenses Page +The submitter (and their copilot) can add expenses to a report from the Expenses page. + +Note, when expenses aren’t on a report, they are **personal expenses**. So you’ll want to make sure you haven’t filtered out **personal expenses** expenses, or you won’t be able to see them. + +1. Find the expense you want to add. (Hint: Use the filters to sort expenses by the desired date range if it is not a recent expense.) +2. Then, select the expense you want to add to a report. You can click Select All to select multiple expenses. +3. Click **Add to Report** in the upper right corner, and choose either an existing report or create a new one. + +# How to code expenses from the Expenses Page +To code expenses from the Expenses page, do the following: + +1. Look for the **Tag**, **Category**, and **Description** columns on the **Expenses** page. +2. Click on the relevant field for a specific expense and add or update the **Category**, **Tag**, or **Description**. + +Note, you can also open up individual expenses by clicking on them to see a detailed look, but coding the expenses from the Expense list is even faster and more convenient! + +# How to export expenses to a CSV file or spreadsheet +If you want to export multiple expenses, run through the below steps: +Select the expenses you want to export by checking the box to the left of each expense. +Then, click **Export To** in the upper right corner of the page, and choose our default CSV format or create your own custom CSV template. + + +# FAQ + +## Can I use the filters and analytics features on the mobile app? +The various features on the Expenses Page are only available while logged into your web account. + +## As a Workspace admin, what submitter expenses can you see? +A Workspace admin can see Processing, Approved, and Reimbursed expenses as long as they were submitted on the workspace that you are an admin. + +If employees submit expense reports on a workspace where you are not an admin, you will not have visibility into those expenses. Additionally, if an expense is left unreported, a workspace admin will not be able to see that expense until it’s been added to a report. + +A Workspace admin can edit the tags and categories on an expense, but if they want to edit the amount, date, or merchant name, the expense will need to be in a Processing state or rejected back to the submitter for changes. +We have more about company card expense reconciliation in this support article. + +## Can I edit multiple expenses at once? +Yes! Select the expenses you want to edit and click **Edit Multiple**. + diff --git a/docs/articles/expensify-classic/expensify-card/Card-Settings.md b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md similarity index 98% rename from docs/articles/expensify-classic/expensify-card/Card-Settings.md rename to docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md index a8d56f267757..3e2eb2deec46 100644 --- a/docs/articles/expensify-classic/expensify-card/Card-Settings.md +++ b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md @@ -1,6 +1,6 @@ --- -title: Expensify Card Settings -description: Admin Card Settings and Features +title: Admin Card Settings and Features +description: An in-depth look into the Expensify Card program's admin controls and settings. --- # Overview diff --git a/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md b/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md deleted file mode 100644 index 9888edd139ac..000000000000 --- a/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Connect to Indirect Integration -description: Connect to Indirect Integration ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/File-A-Dispute.md b/docs/articles/expensify-classic/expensify-card/Dispute-A-Transaction.md similarity index 100% rename from docs/articles/expensify-classic/expensify-card/File-A-Dispute.md rename to docs/articles/expensify-classic/expensify-card/Dispute-A-Transaction.md diff --git a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md index 5c9761b7ff1d..4c216faffc18 100644 --- a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md +++ b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md @@ -5,40 +5,7 @@ description: Get the most out of your Expensify Card with exclusive perks! # Overview -The Expensify Card is packed with perks, both native to our Card program and through exclusive discounts with partnering solutions. The Expensify Card’s primary perks include: -- Access to our premiere Expensify Lounge (with more locations coming soon) -- Swipe to Win, where every swipe has a chance to win fun personalized gifts for you and your closest friends and family members -- And unbeatable cash back incentive with each swipe -Below, we’ll cover all of our exclusive offers in more detail and how to claim discounts with our partners. - -# Expensify Card Perks - -## Access to the Expensify Lounge -Our [world-class lounge](https://use.expensify.com/lounge) is now open for Expensify members and guests to enjoy! - -We invite you to visit our sleek San Francisco lounge, where sweeping city views provide the perfect backdrop for a morning coffee to start your day. - -Enjoy complimentary cocktails and snacks in a vibrant atmosphere with blazing-fast WiFi. Whether you want a place to focus on work, socialize with other members, or simply kick back and relax – our lounge is ready and waiting to welcome you. - -You can sign up for free [here](https://use.expensify.com) if you’re not an Expensify member. If you have any questions, reach out to concierge@expensify.com and [check this out](https://use.expensify.com/lounge) for more info. - -## Swipe to Win -Swipe to Win is a new [Expensify Card](https://use.expensify.com/company-credit-card) perk that gives cardholders the chance to send a gift to a friend, family member, or essential worker on the frontlines! - -Winners can choose to _Send a Smile_ or _Send a Laugh_. To start, we’re offering one gift per option: - -- **Send A Smile:** Champagne by Expensify -- **Send a Laugh:** Jenga Set - -**How to Participate** -It’s easy! Once you have an Expensify Card, you just need to start using it. With each swipe, you're automatically entered to win and have a 1 in 250 chance of getting a prize! - -**How will I know if I’ve won?** -Winners will be notified immediately via the Expensify app, and receive additional instructions on how to choose and send their desired gift. - -If you don't have Expensify notifications turned on yet, here are some helpful guides: -- [Apple Notification Preferences](https://support.apple.com/en-us/HT201925) -- [Android Notification Preferences](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fsupport.google.com%2Fandroid%2Fanswer%2F9079661%3Fhl%3Den) +The Expensify Card is packed with perks, both native to our Card program and through exclusive discounts with partnering solutions. Below, we’ll cover all of our exclusive offers in more detail and how to claim discounts with our partners. # Partner Specific Perks @@ -222,26 +189,3 @@ Stripe Atlas helps removes obstacles typically associated with starting a busine **How to redeem:** Sign up with your Expensify Card. -# FAQ - -## Where is the Expensify Lounge? -The Expensify Lounge is located on the 16th floor of 88 Kearny Street in San Francisco, California, 94108. This is currently our only lounge location, but keep an eye out for more work lounges popping up soon! - -## When is the Expensify Lounge open? -The lounge is open 8 a.m. to 6 p.m. from Monday through Friday, except for national holidays. Capacity is limited, and we are admitting loungers on a first-come, first-served basis, so make sure to get there early! - -## Who can use the lounge workplace? -Customers with an Expensify subscription can use Expensify’s lounge workplace, and any partner who has completed [ExpensifyApproved! University!](https://university.expensify.com/users/sign_in?next=%2Fdashboard) - - - - -# FAQ -This section covers the useful but not as vital information, it should capture commonly queried elements which do not organically form part of the About or How-to sections. - -- What's idiosyncratic or potentially confusing about this feature? -- Is there anything unique about how this feature relates to billing/activity? -- If this feature is released, are there any common confusions that can't be solved by improvements to the product itself? -- Similarly, if this feature hasn't been released, can you predict and pre-empt any potential confusion? -- Is there any general troubleshooting for this feature? - - Note: troubleshooting should generally go in the FAQ, but if there is extensive troubleshooting, such as with integrations, that will be housed in a separate page, stored with and linked from the main page for that feature. diff --git a/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md b/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md deleted file mode 100644 index a8cddcdfdd42..000000000000 --- a/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Third Party Payments -description: Third Party Payments ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Best-Practices.md b/docs/articles/expensify-classic/getting-started/Best-Practices.md deleted file mode 100644 index b02ea9d68fe6..000000000000 --- a/docs/articles/expensify-classic/getting-started/Best-Practices.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Best Practices -description: Best Practices ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Policy-Admins.md b/docs/articles/expensify-classic/getting-started/Policy-Admins.md deleted file mode 100644 index 484350f101a5..000000000000 --- a/docs/articles/expensify-classic/getting-started/Policy-Admins.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Policy Admins -description: Policy Admins ---- -## Resource Coming Soon! 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 d933e66cc2d1..3ad3110bf09b 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 @@ -6,7 +6,7 @@ redirect_from: articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-B # 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. -- See our [US-based VC-Backed Startups](https://help.expensify.com/articles/playbooks/Expensify-Playbook-for-US-based-VC-Backed-Startups) if you are more concerned with top-line revenue growth +- See our [US-based VC-Backed Startups](https://help.expensify.com/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups) if you are more concerned with top-line revenue growth # Who you are As a small to medium-sized business owner, your main aim is to achieve success and grow your business. To achieve your goals, it is crucial that you make worthwhile investments in both your workforce and your business processes. This means providing your employees with the resources they need to generate revenue effectively, while also adopting measures to guarantee that expenses are compliant. @@ -22,23 +22,23 @@ If you don't already have one, go to *[new.expensify.com](https://new.expensify. > **Robyn Gresham** > Senior Accounting Systems Manager at SunCommon -## Step 2: Create a Control Policy -There are three policy types, but for your small business needs we recommend the *Control Plan* for the following reasons: +## Step 2: Create a Control Workspace +There are three workspace types, but for your small business needs we recommend the *Control Plan* for the following reasons: - *The Control Plan* is designed for organizations with a high volume of employee expense submissions, who also rely on compliance controls - The ease of use and mobile-first design of the Control plan can increase employee adoption and participation, leading to better expense tracking and management. - The plan integrates with a variety of tools, including accounting software and payroll systems, providing a seamless and integrated experience - Accounting integrations include QuickBooks Online, Xero, NetSuite, and Sage Intacct, with indirect support from Microsoft Dynamics and any other accounting solution you work with -We recommend creating one single policy for your US entity. This allows you to centrally manage all employees in one “group” while enforcing compliance controls and syncing with your accounting package accordingly. +We recommend creating one single workspace for your US entity. This allows you to centrally manage all employees in one “group” while enforcing compliance controls and syncing with your accounting package accordingly. -To create your Control Policy: +To create your Control Workspace: -1. Go to *Settings > Policies* -2. Select *Group* and click the button that says *New Policy* +1. Go to *Settings > Workspace* +2. Select *Group* and click the button that says *New Workspace* 3. Click *Select* under Control -The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your policy's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s policy settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. That’s a 75% discount off the unbundled price point if you choose to use a different Corporate Card (or no) provider. +The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your workspace's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s workspace settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. That’s a 75% discount off the unbundled price point if you choose to use a different Corporate Card (or no) provider. ## Step 3: Connect your accounting system As a small to medium-sized business, it's important to maintain proper spend management to ensure the success and stability of your organization. This requires paying close attention to your expenses, streamlining your financial processes, and making sure that your financial information is accurate, compliant, and transparent. Include best practices such as: @@ -49,17 +49,17 @@ As a small to medium-sized business, it's important to maintain proper spend man You do this by synchronizing Expensify and your accounting package as follows: -1. Click *Settings > Policies* +1. Click *Settings > Workspace* 2. Navigate to the *Connections* tab 3. Select your accounting system 4. Follow the prompts to connect your accounting package Check out the links below for more information on how to connect to your accounting solution: -- *[QuickBooks Online](https://community.expensify.com/discussion/4833/how-to-connect-your-policy-to-quickbooks-online)* -- *[Xero](https://community.expensify.com/discussion/5282/how-to-connect-your-policy-to-xero)* -- *[NetSuite](https://community.expensify.com/discussion/5212/how-to-connect-your-policy-to-netsuite-token-based-authentication)* -- *[Sage Intacct](https://community.expensify.com/discussion/4777/how-to-connect-to-sage-intacct-user-based-permissions-expense-reports)* -- *[Other Accounting System](https://community.expensify.com/discussion/5271/how-to-set-up-an-indirect-accounting-integration) +- *[QuickBooks Online](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online#gsc.tab=0)* +- *[Xero](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Xero#gsc.tab=0)* +- *[NetSuite](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/NetSuite#gsc.tab=0)* +- *[Sage Intacct](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct#gsc.tab=0)* +- *[Other Accounting System](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Indirect-Accounting-Integrations#gsc.tab=0) *“Employees really appreciate how easy it is to use, and the fact that the reimbursement drops right into their bank account. Since most employees are submitting expenses from their phones, the ease of use of the app is critical.”* @@ -82,15 +82,15 @@ Head over to the *Categories* tab to set compliance controls on your newly impor Tags in Expensify often relate to departments, projects/customers, classes, and so on. And in some cases they are *required* to be selected on every transactions. And in others, something like *departments* is a static field, meaning we could set it as an employee default and not enforce the tag selection with each expense. *Make Tags Required* -In the tags tab in your policy settings, you’ll notice the option to enable the “Required” field. This makes it so any time an employee doesn’t assign a tag to an expense, we’ll flag a violation on it and notify both the employee and the approver. +In the tags tab in your workspace settings, you’ll notice the option to enable the “Required” field. This makes it so any time an employee doesn’t assign a tag to an expense, we’ll flag a violation on it and notify both the employee and the approver. - *Note:* In general, we take prior selection into account, so anytime you select a tag in Expensify, we’ll pre-populate that same field for any subsequent expense. It’s completely interchangeable, and there for convenience. *Set Tags as an Employee Default* -Separately, if your policy is connected to NetSuite or Sage Intacct, you can set departments, for example, as an employee default. All that means is we’ll apply the department (for example) that’s assigned to the employee record in your accounting package and apply that to every exported transaction, eliminating the need for the employee to have to manually select a department for each expense. +Separately, if your workspace is connected to NetSuite or Sage Intacct, you can set departments, for example, as an employee default. All that means is we’ll apply the department (for example) that’s assigned to the employee record in your accounting package and apply that to every exported transaction, eliminating the need for the employee to have to manually select a department for each expense. ## Step 6: Set rules for all expenses regardless of categorization -In the Expenses tab in your group Control policy, you’ll notice a *Violations* section designed to enforce top-level compliance controls that apply to every expense, for every employee in your policy. We recommend the following confiuration: +In the Expenses tab in your group Control workspace, you’ll notice a *Violations* section designed to enforce top-level compliance controls that apply to every expense, for every employee in your workspace. We recommend the following confiuration: *Max Expense Age: 90 days (or leave it blank)* This will enable Expensify to catch employee reimbursement requests that are far too outdated for reimbursement, and present them as a violations. If you’d prefer a different time window, you can edit it accordingly @@ -106,17 +106,17 @@ Receipts are important, and in most cases you prefer an itemized receipt. Howeve At this point, you’ve set enough compliance controls around categorical spend and general expenses for all employees, such that you can put trust in our solution to audit all expenses up front so you don’t have to. Next, let’s dive into how we can comfortably take on more automation, while relying on compliance controls to capture bad behavior (or better yet, instill best practices in our employees). ## Step 7: Set up scheduled submit -For an efficient company, we recommend setting up [Scheduled Submit](https://community.expensify.com/discussion/4476/how-to-enable-scheduled-submit-for-a-group-policy) on a *Daily* frequency: +For an efficient company, we recommend setting up [Scheduled Submit](https://help.expensify.com/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit#gsc.tab=0) on a *Daily* frequency: -- Click *Settings > Policies* -- From here, select your group collect policy -- Within your policy settings, select the *Reports* tab +- Click *Settings > Workspace* +- From here, select your group collect workspace +- Within your workspace settings, select the *Reports* tab - You’ll notice *Scheduled Submit* is located directly under *Report Basics* - Choose *Daily* -Between Expensify's SmartScan technology, automatic categorization, and [DoubleCheck](https://community.expensify.com/discussion/5738/deep-dive-how-does-concierge-receipt-audit-work) features, your employees shouldn't need to do anything more than swipe their Expensify Card or take a photo of their receipt. +Between Expensify's SmartScan technology, automatic categorization, and [DoubleCheck](https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports) features, your employees shouldn't need to do anything more than swipe their Expensify Card or take a photo of their receipt. -Expenses with violations will stay behind for the employee to fix, while expenses that are “in-policy” will move into an approver’s queue to mitigate any potential for delays. Scheduled Submit will ensure all expenses are submitted automatically for approval. +Expenses with violations will stay behind for the employee to fix, while expenses that are “in-workspace” will move into an approver’s queue to mitigate any potential for delays. Scheduled Submit will ensure all expenses are submitted automatically for approval. ![Scheduled submit](https://help.expensify.com/assets/images/playbook-scheduled-submit.png){:width="100%"} @@ -147,10 +147,10 @@ You only need to do this once: you are fully set up for not only reimbursing exp ## Step 9: Invite employees and set an approval workflow *Select an Approval Mode* -We recommend you select *Advanced Approval* as your Approval Mode to set up a middle-management layer of a approval. If you have a single layer of approval, we recommend selecting [Submit & Approve](https://community.expensify.com/discussion/5643/deep-dive-submit-and-approve). But if *Advanced Approval* if your jam, keep reading! +We recommend you select *Advanced Approval* as your Approval Mode to set up a middle-management layer of approval. If you have a single layer of approval, we recommend selecting [Submit & Approve](https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows#gsc.tab=0). But if *Advanced Approval* is your jam, keep reading! *Import your employees in bulk via CSV* -Given the amount of employees you have, it’s best you import employees in bulk via CSV. You can learn more about using a CSV file to bulk upload employees with *Advanced Approval [here](https://community.expensify.com/discussion/5735/deep-dive-the-ins-and-outs-of-advanced-approval)* +Given the amount of employees you have, it’s best you import employees in bulk via CSV. You can learn more about using a CSV file to bulk upload employees with *Advanced Approval [here](https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows#gsc.tab=0)* ![Bulk import your employees](https://help.expensify.com/assets/images/playbook-impoort-employees.png){:width="100%"} @@ -162,8 +162,8 @@ In this case we recommend setting *Manually approve all expenses over: $0* ## Step 10: Configure Auto-Approval Knowing you have all the control you need to review reports, we recommend configuring auto-approval for *all reports*. Why? Because you’ve already put reports through an entire approval workflow, and manually triggering reimbursement is an unnecessary action at this stage. -1. Navigate to *Settings > Policies > Group > [Policy Name] > Reimbursement* -2. Set your *Manual Reimbursement threshold to $20,0000* +1. Navigate to *Settings > Workspace > Group > [Workspace Name] > Reimbursement* +2. Set your *Manual Reimbursement threshold to $20,000* ## Step 11: Enable Domains and set up your corporate card feed for employees Expensify is optimized to work with corporate cards from all banks – or even better, use our own perfectly integrated *[Expensify Card](https://use.expensify.com/company-credit-card)*. The first step for connecting to any bank you use for corporate cards, and the Expensify Card is to validate your company’s domain in Domain settings. @@ -191,7 +191,7 @@ As mentioned above, we’ll be able to pull in transactions as they post (daily) Expensify provides a corporate card with the following features: - Up to 2% cash back (up to 4% in your first 3 months!) -- [SmartLimits](https://community.expensify.com/discussion/4851/deep-dive-what-are-unapproved-expense-limits#latest) to control what each individual cardholder can spend +- [SmartLimits](https://help.expensify.com/articles/expensify-classic/expensify-card/Card-Settings) to control what each individual cardholder can spend - A stable, unbreakable real-time connection (third-party bank feeds can run into connectivity issues) - Receipt compliance - informing notifications (eg. add a receipt!) for users *as soon as the card is swiped* - A 50% discount on the price of all Expensify plans @@ -202,8 +202,8 @@ The Expensify Card is recommended as the most efficient way to manage your compa Here’s how to enable it: -1. There are *two ways* you can [apply for the Expensify Card](https://community.expensify.com/discussion/4874/how-to-apply-for-the-expensify-card) - - *Via your Inbox* +1. There are *two ways* you can [apply for the Expensify Card](https://help.expensify.com/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company) + - *Via your tasks on the Home page* - *Via Domain Settings* - Go to Settings > Domain > Company Cards > Enable Expensify Card 2. Assign the cards to your employees 3. Set *SmartLimits*: @@ -212,14 +212,14 @@ Here’s how to enable it: Once the Expensify Cards have been assigned, each employee will be prompted to enter their mailing address so they can receive their physical card. In the meantime, a virtual card will be ready to use immediately. -If you have an accounting system we directly integrate with, check out how we take automation a step further with [Continuous Reconciliation](https://community.expensify.com/discussion/7335/faq-what-is-the-expensify-card-auto-reconciliation-process). We’ll create an Expensify Card clearing and liability account for you. Each time settlement occurs, we’ll take the total amount of your purchases and create a journal entry that credits the settlement account and debits the liability account - saving you hours of manual reconciliation work at the end of your statement period. +If you have an accounting system we directly integrate with, check out how we take automation a step further with [Continuous Reconciliation](https://help.expensify.com/articles/expensify-classic/expensify-card/Auto-Reconciliation). We’ll create an Expensify Card clearing and liability account for you. Each time settlement occurs, we’ll take the total amount of your purchases and create a journal entry that credits the settlement account and debits the liability account - saving you hours of manual reconciliation work at the end of your statement period. ## Step 12: Set up Bill Pay and Invoicing As a small business, managing bills and invoices can be a complex and time-consuming task. Whether you receive bills from vendors or need to invoice clients, it's important to have a solution that makes the process simple, efficient, and cost-effective. Here are some of the key benefits of using Expensify for bill payments and invoicing: - Flexible payment options: Expensify allows you to pay your bills via ACH, credit card, or check, so you can choose the option that works best for you (US businesses only). -- Free, No Fees: The bill pay and invoicing features come included with every policy and workspace, so you won't need to pay any additional fees. +- Free, No Fees: The bill pay and invoicing features come included with every workspace and workspace, so you won't need to pay any additional fees. - Integration with your business bank account: With your business bank account verified, you can easily link your finances to receive payment from customers when invoices are paid. Let’s first chat through how Bill Pay works @@ -244,7 +244,7 @@ Reports, invoices, and bills are largely the same, in theory, just with differen 2. Add all of the expenses/transactions tied to the Invoice 3. Enter the recipient’s email address, a memo if needed, and a due date for when it needs to get paid, and click *Send* -You’ll notice it’s a slightly different flow from creating a Bill. Here, you are adding the transactions tied to the Invoice, and establishing a due date for when it needs to get paid. If you need to apply any markups, you can do so from your policy settings under the Invoices tab. Your customers can pay their invoice in Expensify via ACH, or Check, or Credit Card. +You’ll notice it’s a slightly different flow from creating a Bill. Here, you are adding the transactions tied to the Invoice, and establishing a due date for when it needs to get paid. If you need to apply any markups, you can do so from your workspace settings under the Invoices tab. Your customers can pay their invoice in Expensify via ACH, or Check, or Credit Card. ## Step 13: Run monthly, quarterly and annual reporting At this stage, reporting is important and given that Expensify is the primary point of entry for all employee spend, we make reporting visually appealing and wildly customizable. @@ -266,7 +266,7 @@ Our pricing model is unique in the sense that you are in full control of your bi To set your subscription, head to: -1. Settings > Policies +1. Settings > Workspace 2. Select *Group* 3. Scroll down to *Subscription* 4. Select *Annual Subscription* @@ -281,4 +281,4 @@ Now that we’ve gone through all of the steps for setting up your account, let 4. Click *Accept Terms* # You’re all set! -Congrats, you are all set up! If you need any assistance with anything mentioned above or would like to understand other features available in Expensify, reach out to your Setup Specialist directly in *[new.expensify.com](https://new.expensify.com)*. Don’t have one yet? Create a Control Policy, and we’ll automatically assign a dedicated Setup Specialist to you. +Congrats, you are all set up! If you need any assistance with anything mentioned above or would like to understand other features available in Expensify, reach out to your Setup Specialist directly in *[new.expensify.com](https://new.expensify.com)*. Don’t have one yet? Create a Control Workspace, and we’ll automatically assign a dedicated Setup Specialist to you. diff --git a/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md b/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md new file mode 100644 index 000000000000..267c938a3edf --- /dev/null +++ b/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md @@ -0,0 +1,43 @@ +--- +title: Fringe Benefits +description: How to track your Fringe Benefits +--- +# Overview +If you’re looking to track and report expense data to calculate Fringe Benefits Tax (FBT), you can use Expensify’s special workflow that allows you to capture extra information and use a template to export to a spreadsheet. + +# How to set up Fringe Benefit Tax + +## Add Attendee Count Tags +First, you’ll need to add these two tags to your Workspace: +1) Number of Internal Attendees +2) Number of External Attendees + +These tags must be named exactly as written above, ensuring there are no extra spaces at the beginning or at the end. You’ll need to set the tags to be numbers 00 - 10 or whatever number you wish to go up to (up to the maximum number of attendees you would expect at any one time), one tag per number i.e. “01”, “02”, “03” etc. These tags can be added in addition to those that are pulled in from your accounting solution. Follow these [instructions](https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/Tags#gsc.tab=0) to add tags. + +## Add Payroll Code +Go to **Settings > Workspaces > Group > _Workspace Name_ > Categories** and within the categories you wish to track FBT against, select **Edit Category** and add the code “TAG”: + +## Enable Workflow +Once you’ve added both tags (Internal Attendees and External Attendees) and added the payroll code “TAG” to FBT categories, you can send a request to Expensify at concierge@expensify.com to enable the FBT workflow. Please send the following request: +>“Can you please add the custom workflow/DEW named FRINGE_BENEFIT_TAX to my company workspace named ?” +Once the FBT workflow is enabled, it will require anything with the code “TAG” to include the two attendee count tags in order to be submitted. + + +# For Users +Once these steps are completed, users who create expenses coded with any category that has the payroll code “TAG” (e.g. Entertainment Expenses) but don’t add the internal and external attendee counts, will not be able to submit their expenses. +# For Admins +You are now able to create and run a report, which shows all expenses under these categories and also shows the number of internal and external attendees. Because we don’t presume to know all of the data points you wish to capture, you’ll need to create a Custom CSV export. +Here are a couple of examples of Excel formulas to use to report on attendees: +- `{expense:tag:ntag-1}` outputs the first tag the user chooses. +- `{expense:tag:ntag-3}` outputs the third tag the user chooses. + +Your expenses may have multiple levels of coding, i.e.: +- GL Code (Category) +- Department (Tag 1) +- Location (Tag 2) +- Number of Internal Attendees (Tag 3) +- Number of External Attendees (Tag 4) + +In the above case, you’ll want to use `{expense:tag:ntag-3}` and `{expense:tag:ntag-4}` as formulas to report on the number of internal and external attendees. + +Our article on [Custom Templates](https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates#gsc.tab=0) shows how to create a custom CSV. diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md index e9077fc40a50..ecdea4699ee0 100644 --- a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md @@ -64,6 +64,8 @@ Before completing the steps below, you will need Workday Report Writer access to - Note: _if there is field data you want to import that is not listed above, or you have any special requests, let your Expensify Account Manager know and we will work with you to accommodate the request._ 4. Rename the columns so they match Expensify's API key names (The full list of names are found here): - employeeID + - customField1 + - customField2 - firstName - lastName - employeeEmail diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md similarity index 61% rename from docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md rename to docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md index 3ee1c8656b4b..f2978434959b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md @@ -1,5 +1,5 @@ --- -title: Coming Soon +title: Add Members to your Workspace description: Coming Soon --- ## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md index 1f69c1eee8f4..4c64ab1cefe4 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md @@ -52,28 +52,28 @@ This document explains how to manage employee expense reports and approval workf - *Final Approver (Finance/Accountant):* This is the person listed as the 'Approves to' in the Settings of the Second Approver. - This is what this setup looks like in the Workspace Members table. - Bryan submits his reports to Jim for 1st level approval. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot showing the People section of the workspace]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_1.png){:width="100%"} - All of the reports Jim approves are submitted to Kevin. Kevin is the 'approves to' in Jim's Settings. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_2.png){:width="100%"} - All of the reports Kevin approves are submitted to Lucy. Lucy is the 'approves to' in Kevin's Settings. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor Approves to]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_3.png){:width="100%"} - Lucy is the final approver, so she doesn't submit her reports to anyone for review. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor Final Approver]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_4.png){:width="100%"} - The final outcome: The member in the Submits To line is different than the person noted as the Approves To. ### Adding additional approver levels - You can also set a specific approver for Reports Totals in Settings. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor Approves to]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_5.png){:width="100%"} - An example: The submitter's manager can approve any report up to a certain limit, let's say $500, and forward it to accounting. However, if a report is over that $500 limit, it has to be also approved by the department head before being forwarded to accounting. - To configure, click on Edit Settings next to the approving manager's email address and set the "If Report Total is Over" and "Then Approves to" fields. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Workspace Member Settings]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_6.png){:width="100%"} +![Screenshot of Policy Member Editor Configure]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_7.png){:width="100%"} ### Setting category approvals @@ -89,7 +89,7 @@ This document explains how to manage employee expense reports and approval workf - To add a category approver in your Workspace: - Navigate to *Settings > Policies > Group > [Workspace Name] > Categories* - Click *"Edit Settings"* next to the category that requires the additional approver - - Select an approver and click *“Save”* + - Select an approver and click *"Save"* #### Tag approver @@ -106,4 +106,4 @@ Category and Tag approvers are inserted at the beginning of the approval workflo ### Workflow enforcement -- If you want to ensure your employees cannot override the workflow you set - enable workflow enforcement by following the steps below. As a Workspace Admin, you can choose to enforce your approval workflow by going. \ No newline at end of file +- If you want to ensure your employees cannot override the workflow you set - enable workflow enforcement. As a Workspace Admin, you can choose to enforce your approval workflow by going to Settings > Workspaces > Group > [Workspace Name] > People > Approval Mode. When enabled (which is the default setting for a new workspace), submitters and approvers must adhere to the set approval workflow (recommended). This setting does not apply to Workspace Admins, who are free to submit outside of this workflow diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Removing-Members.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Removing-Members.md index 76ebba9ef76b..33ffe7172603 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Removing-Members.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Removing-Members.md @@ -11,6 +11,8 @@ Removing a member from a workspace disables their ability to use the workspace. 3. Select the member you'd like to remove and click the **Remove** button at the top of the Members table. 4. If this member was an approver, make sure that reports are not routing to them in the workflow. +![image of members table in a workspace]({{site.url}}/assets/images/ExpensifyHelp_RemovingMembers.png){:width="100%"} + # FAQ ## Will reports from this member on this workspace still be available? diff --git a/docs/articles/expensify-classic/send-payments/Pay-Invoices.md b/docs/articles/expensify-classic/send-payments/Pay-Invoices.md deleted file mode 100644 index e5e6799c268c..000000000000 --- a/docs/articles/expensify-classic/send-payments/Pay-Invoices.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Pay Invoices -description: Pay Invoices ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md index 3ee1c8656b4b..a1916465fca8 100644 --- a/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md +++ b/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md @@ -1,5 +1,47 @@ --- -title: Coming Soon -description: Coming Soon +title: Reimbursement +description: Enable reimbursement and reimburse expense reports --- -## Resource Coming Soon! + + +# Overview +Reimbursement in Expensify is quick, easy, and completely free. Let Expensify do the tedious work for you by taking advantage of features to automate employee reimbursement. + +# How to Enable Reimbursement +There are several options for reimbursing employees in Expensify. The options available will depend on which country your business bank account is domiciled in. + +## Direct Reimbursement + +Direct reimbursement is available to companies who have a verified US bank account and are reimbursing employees within the US. To use direct reimbursement, you must have a US business bank account verified in Expensify. + +A Workspace admin can enable direct reimbursement via **Settings > Workspaces > Workspace Name > Reimbursement > Direct**. + +**Additional features under Reimbursement > Direct:** + - Select a **default reimburser** for the Workspace from the dropdown menu. The default reimburser is the person who will receive notifications to reimburse reports in Expensify. You’ll be able to choose among all Workspace Admins who have access to the business bank account. + - Set a **default withdrawal account** for the Workspace. This will set a default bank account that report reimbursements are withdrawn from. + - Set a **manual reimbursement threshold** to automate reimbursement. Reports whose total falls under the manual reimbursement threshhold will be reimbursed automatocally upon final approval; reports whose total falls above the threshhold will need to be reimbursed manually by the default reimburser. + +Expensify also offers direct global reimbursement to some companies with verified bank accounts in USD, GBP, EUR and AUD who are reimbursing employees internationally. For more information about Global Reimbursement, see LINK + +## Indirect Reimbursement + +Indirect reimbursement is available to all companies in Expensify and no bank account is required. Indirect reimbursement indicates that the report will be reimbursed outside of Expensify. + +A Workspace admin can enanble indirect reimbursement via **Settings > Workspaces > Workspace Name > Reimbursement > Indirect**. + +**Additional features under Reimbursement > Indirect:** +If you reimburse through a seperate system or through payroll, Expensify can collect and export employee bank account details for you. Just reach out to your Account Manager or concierge@expensify.com for us to add the Reimbursement Details Export format to the account. + +# FAQ + +## How do I export employee bank account details once the Reimbursement Details Export format is added to my account? + +Employee bank account details can be exported from the Reports page by selecting the relevant Approved reports and then clicking **Export to > Reimbursement Details Export**. + +## Is it possible to change the name of a verified business bank account in Expensify? + +Bank account names can be updated via **Settings > Accounts > Payments** and clicking the pencil icon next to the bank account name. + +## What is the benefit of setting a default reimburser? + +The main benefit of being defined as the "reimburser" in the Workspace settings is that this user will receive notifications on their Home page alerting them when reports need to be reimbursed. diff --git a/docs/assets/images/ExpensifyHelp_CloseAccount_Mobile.png b/docs/assets/images/ExpensifyHelp_CloseAccount_Mobile.png new file mode 100644 index 000000000000..2e11b7eb1f49 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_CloseAccount_Mobile.png differ diff --git a/docs/assets/images/ExpensifyHelp_RemovingMembers.png b/docs/assets/images/ExpensifyHelp_RemovingMembers.png new file mode 100644 index 000000000000..1e0157476fbf Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_RemovingMembers.png differ diff --git a/docs/assets/images/attendee-tracking.png b/docs/assets/images/attendee-tracking.png new file mode 100644 index 000000000000..66ab22b6efe7 Binary files /dev/null and b/docs/assets/images/attendee-tracking.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv new file mode 100644 index 000000000000..d4fb7723bd0f --- /dev/null +++ b/docs/redirects.csv @@ -0,0 +1,2 @@ +sourceURL,targetURL +https://community.expensify.com/discussion/5634/deep-dive-how-long-will-it-take-for-me-to-receive-my-reimbursement,https://help.expensify.com/articles/expensify-classic/get-paid-back/reports/Reimbursements diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index cdd6e463d12b..dc1fdd58b542 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.94 + 1.3.96 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.94.0 + 1.3.96.6 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 34d617586b7b..910ad06f7517 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.94 + 1.3.96 CFBundleSignature ???? CFBundleVersion - 1.3.94.0 + 1.3.96.6 diff --git a/metro.config.js b/metro.config.js index aaeea8e015a9..a620d3adc926 100644 --- a/metro.config.js +++ b/metro.config.js @@ -6,13 +6,15 @@ require('dotenv').config(); const defaultConfig = getDefaultConfig(__dirname); -const isUsingMockAPI = process.env.E2E_TESTING === 'true'; +const isE2ETesting = process.env.E2E_TESTING === 'true'; -if (isUsingMockAPI) { +if (isE2ETesting) { // eslint-disable-next-line no-console console.log('⚠️⚠️⚠️⚠️ Using mock API ⚠️⚠️⚠️⚠️'); } +const e2eSourceExts = ['e2e.js', 'e2e.ts']; + /** * Metro configuration * https://facebook.github.io/metro/docs/configuration @@ -22,10 +24,11 @@ if (isUsingMockAPI) { const config = { resolver: { assetExts: defaultAssetExts, - sourceExts: [...defaultSourceExts, 'jsx'], + // When we run the e2e tests we want files that have the extension e2e.js to be resolved as source files + sourceExts: [...(isE2ETesting ? e2eSourceExts : []), ...defaultSourceExts, 'jsx'], resolveRequest: (context, moduleName, platform) => { const resolution = context.resolveRequest(context, moduleName, platform); - if (isUsingMockAPI && moduleName.includes('/API')) { + if (isE2ETesting && moduleName.includes('/API')) { const originalPath = resolution.filePath; const mockPath = originalPath.replace('src/libs/API.ts', 'src/libs/E2E/API.mock.js').replace('/src/libs/API.js/', 'src/libs/E2E/API.mock.js'); // eslint-disable-next-line no-console diff --git a/package-lock.json b/package-lock.json index a93b482bc004..ec183c9b946c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "new.expensify", - "version": "1.3.94-0", + "version": "1.3.96-6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.94-0", + "version": "1.3.96-6", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@expensify/react-native-web": "0.18.15", + "@dotlottie/react-player": "^1.6.3", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", "@formatjs/intl-listformat": "^7.2.2", @@ -21,6 +21,7 @@ "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.0.1", "@kie/mock-github": "^1.0.0", + "@lottiefiles/react-lottie-player": "^3.5.3", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "^1.17.10", @@ -61,7 +62,7 @@ "jest-expo": "^49.0.0", "jest-when": "^3.5.2", "lodash": "4.17.21", - "lottie-react-native": "^6.3.1", + "lottie-react-native": "^6.4.0", "mapbox-gl": "^2.15.0", "moment": "^2.29.4", "moment-timezone": "^0.5.31", @@ -97,7 +98,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.100", + "react-native-onyx": "1.0.111", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -115,8 +116,8 @@ "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "^3.6.0", "react-native-vision-camera": "^2.16.2", + "react-native-web": "^0.19.9", "react-native-web-linear-gradient": "^1.1.2", - "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", @@ -2940,6 +2941,14 @@ "node": ">=10.0.0" } }, + "node_modules/@dotlottie/react-player": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@dotlottie/react-player/-/react-player-1.6.3.tgz", + "integrity": "sha512-wktLksV1LzV2qAAMocdBxn2e0J7XUraztLH2DnrlBYUgdy5Cz4FyV8+BPLftcyVD7r/4+0X448hEvK7tFQiLng==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@dword-design/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@dword-design/dedent/-/dedent-0.7.0.tgz", @@ -3673,25 +3682,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@expensify/react-native-web": { - "version": "0.18.15", - "resolved": "https://registry.npmjs.org/@expensify/react-native-web/-/react-native-web-0.18.15.tgz", - "integrity": "sha512-xE3WdGKY4SRLfIrimUlgP78ZsDaWy3g+KIO8mpxTm9zCXeX/sgEYs6QvhFghgEhhp7Y1bLH9LWTKiZy9LZM8EA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.6", - "create-react-class": "^15.7.0", - "fbjs": "^3.0.4", - "inline-style-prefixer": "^6.0.1", - "normalize-css-color": "^1.0.2", - "postcss-value-parser": "^4.2.0", - "styleq": "^0.1.2" - }, - "peerDependencies": { - "react": "^17.0.2 || ^18.0.0", - "react-dom": "^17.0.2 || ^18.0.0" - } - }, "node_modules/@expo/bunyan": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@expo/bunyan/-/bunyan-4.0.0.tgz", @@ -7480,6 +7470,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@lottiefiles/react-lottie-player": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@lottiefiles/react-lottie-player/-/react-lottie-player-3.5.3.tgz", + "integrity": "sha512-6pGbiTMjGnPddR1ur8M/TIDCiogZMc1aKIUbMEKXKAuNeYwZ2hvqwBJ+w5KRm88ccdcU88C2cGyLVsboFlSdVQ==", + "dependencies": { + "lottie-web": "^5.10.2" + }, + "peerDependencies": { + "react": "16 - 18" + } + }, "node_modules/@lwc/eslint-plugin-lwc": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@lwc/eslint-plugin-lwc/-/eslint-plugin-lwc-0.11.0.tgz", @@ -28080,16 +28081,6 @@ "sha.js": "^2.4.8" } }, - "node_modules/create-react-class": { - "version": "15.7.0", - "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.7.0.tgz", - "integrity": "sha512-QZv4sFWG9S5RUvkTYWbflxeZX+JG7Cz0Tn33rQBJ+WFQTqTfUTjMjiv9tnfXazjsO5r0KhPs+AqCjyrQX6h2ng==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - }, "node_modules/cross-fetch": { "version": "3.1.5", "license": "MIT", @@ -41050,15 +41041,23 @@ } }, "node_modules/lottie-react-native": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.3.1.tgz", - "integrity": "sha512-M18nAVYeGMF//bhL27D2zuMcrFPH0jbD/deBvcWi0CCcfZf6LQfx45xt+cuDqwr5nh6dMm+ta8KfZJmkbNhtlg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.4.0.tgz", + "integrity": "sha512-wFO/gLPN1KliyznBa8OtYWkc9Vn9OEmIg1/b1536KANFtGaFAeoAGhijVxYKF3UPKJgjJYFmqg0W//FVrSXj+g==", "peerDependencies": { + "@dotlottie/react-player": "^1.6.1", + "@lottiefiles/react-lottie-player": "^3.5.3", "react": "*", "react-native": ">=0.46", "react-native-windows": ">=0.63.x" }, "peerDependenciesMeta": { + "@dotlottie/react-player": { + "optional": true + }, + "@lottiefiles/react-lottie-player": { + "optional": true + }, "react-native-windows": { "optional": true } @@ -44728,12 +44727,6 @@ "url": "https://github.com/sponsors/antelle" } }, - "node_modules/normalize-css-color": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/normalize-css-color/-/normalize-css-color-1.0.2.tgz", - "integrity": "sha512-jPJ/V7Cp1UytdidsPqviKEElFQJs22hUUgK5BOPHTwOonNCk7/2qOxhhqzEajmFrWJowADFfOFh1V+aWkRfy+w==", - "license": "BSD-3-Clause" - }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -47853,17 +47846,17 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.100", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.100.tgz", - "integrity": "sha512-m4bOF/uOtYpfL83fqoWhw7TYV4oKGXt0sfGoel/fhaT1HzXKheXc//ibt/G3VrTCf5nmRv7bXgsXkWjUYLH3UQ==", + "version": "1.0.111", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.111.tgz", + "integrity": "sha512-6drd5Grhkyq4oyt2+Bu6t7JYK5tqaARc0YP7taEHK9jLbhjdC4E9MPLJR2FVXiORkQCPOoyy1Gqmb4AUVIsvxg==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", "underscore": "^1.13.1" }, "engines": { - "node": "16.15.1", - "npm": "8.11.0" + "node": ">=16.15.1 <=18.17.1", + "npm": ">=8.11.0 <=9.6.7" }, "peerDependencies": { "idb-keyval": "^6.2.1", @@ -48143,7 +48136,6 @@ "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", "@react-native/normalize-color": "^2.1.0", @@ -48167,23 +48159,10 @@ "react-native-web": "*" } }, - "node_modules/react-native-web-lottie": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/react-native-web-lottie/-/react-native-web-lottie-1.4.4.tgz", - "integrity": "sha512-W0jZiOf2u3Us6yASdpgAuL1+3Gw1EU/Wi5QAf6brzhXmJnq6/FMGCTf5zvSaX0yIurr9qcYB40DwAb4HwA6frg==", - "license": "MIT", - "dependencies": { - "lottie-web": "^5.7.1" - }, - "peerDependencies": { - "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 + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" }, "node_modules/react-native-webview": { "version": "11.23.0", @@ -58542,6 +58521,12 @@ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", "dev": true }, + "@dotlottie/react-player": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@dotlottie/react-player/-/react-player-1.6.3.tgz", + "integrity": "sha512-wktLksV1LzV2qAAMocdBxn2e0J7XUraztLH2DnrlBYUgdy5Cz4FyV8+BPLftcyVD7r/4+0X448hEvK7tFQiLng==", + "requires": {} + }, "@dword-design/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@dword-design/dedent/-/dedent-0.7.0.tgz", @@ -58976,20 +58961,6 @@ } } }, - "@expensify/react-native-web": { - "version": "0.18.15", - "resolved": "https://registry.npmjs.org/@expensify/react-native-web/-/react-native-web-0.18.15.tgz", - "integrity": "sha512-xE3WdGKY4SRLfIrimUlgP78ZsDaWy3g+KIO8mpxTm9zCXeX/sgEYs6QvhFghgEhhp7Y1bLH9LWTKiZy9LZM8EA==", - "requires": { - "@babel/runtime": "^7.18.6", - "create-react-class": "^15.7.0", - "fbjs": "^3.0.4", - "inline-style-prefixer": "^6.0.1", - "normalize-css-color": "^1.0.2", - "postcss-value-parser": "^4.2.0", - "styleq": "^0.1.2" - } - }, "@expo/bunyan": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@expo/bunyan/-/bunyan-4.0.0.tgz", @@ -61908,6 +61879,14 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", "dev": true }, + "@lottiefiles/react-lottie-player": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@lottiefiles/react-lottie-player/-/react-lottie-player-3.5.3.tgz", + "integrity": "sha512-6pGbiTMjGnPddR1ur8M/TIDCiogZMc1aKIUbMEKXKAuNeYwZ2hvqwBJ+w5KRm88ccdcU88C2cGyLVsboFlSdVQ==", + "requires": { + "lottie-web": "^5.10.2" + } + }, "@lwc/eslint-plugin-lwc": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@lwc/eslint-plugin-lwc/-/eslint-plugin-lwc-0.11.0.tgz", @@ -76886,15 +76865,6 @@ "sha.js": "^2.4.8" } }, - "create-react-class": { - "version": "15.7.0", - "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.7.0.tgz", - "integrity": "sha512-QZv4sFWG9S5RUvkTYWbflxeZX+JG7Cz0Tn33rQBJ+WFQTqTfUTjMjiv9tnfXazjsO5r0KhPs+AqCjyrQX6h2ng==", - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - }, "cross-fetch": { "version": "3.1.5", "requires": { @@ -86068,9 +86038,9 @@ } }, "lottie-react-native": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.3.1.tgz", - "integrity": "sha512-M18nAVYeGMF//bhL27D2zuMcrFPH0jbD/deBvcWi0CCcfZf6LQfx45xt+cuDqwr5nh6dMm+ta8KfZJmkbNhtlg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.4.0.tgz", + "integrity": "sha512-wFO/gLPN1KliyznBa8OtYWkc9Vn9OEmIg1/b1536KANFtGaFAeoAGhijVxYKF3UPKJgjJYFmqg0W//FVrSXj+g==", "requires": {} }, "lottie-web": { @@ -88768,11 +88738,6 @@ "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==" }, - "normalize-css-color": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/normalize-css-color/-/normalize-css-color-1.0.2.tgz", - "integrity": "sha512-jPJ/V7Cp1UytdidsPqviKEElFQJs22hUUgK5BOPHTwOonNCk7/2qOxhhqzEajmFrWJowADFfOFh1V+aWkRfy+w==" - }, "normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -91053,9 +91018,9 @@ } }, "react-native-onyx": { - "version": "1.0.100", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.100.tgz", - "integrity": "sha512-m4bOF/uOtYpfL83fqoWhw7TYV4oKGXt0sfGoel/fhaT1HzXKheXc//ibt/G3VrTCf5nmRv7bXgsXkWjUYLH3UQ==", + "version": "1.0.111", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.111.tgz", + "integrity": "sha512-6drd5Grhkyq4oyt2+Bu6t7JYK5tqaARc0YP7taEHK9jLbhjdC4E9MPLJR2FVXiORkQCPOoyy1Gqmb4AUVIsvxg==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -91231,7 +91196,6 @@ "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", "@react-native/normalize-color": "^2.1.0", @@ -91246,8 +91210,7 @@ "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 + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" } } }, @@ -91257,14 +91220,6 @@ "integrity": "sha512-SmUnpwT49CEe78pXvIvYf72Es8Pv+ZYKCnEOgb2zAKpEUDMo0+xElfRJhwt5nfI8krJ5WbFPKnoDgD0uUjAN1A==", "requires": {} }, - "react-native-web-lottie": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/react-native-web-lottie/-/react-native-web-lottie-1.4.4.tgz", - "integrity": "sha512-W0jZiOf2u3Us6yASdpgAuL1+3Gw1EU/Wi5QAf6brzhXmJnq6/FMGCTf5zvSaX0yIurr9qcYB40DwAb4HwA6frg==", - "requires": { - "lottie-web": "^5.7.1" - } - }, "react-native-webview": { "version": "11.23.0", "requires": { diff --git a/package.json b/package.json index d2b851900222..3576d4a8bd72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.94-0", + "version": "1.3.96-6", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -49,8 +49,9 @@ "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", - "test:e2e:main": "node tests/e2e/testRunner.js --development --branch main --skipCheckout", - "test:e2e:delta": "node tests/e2e/testRunner.js --development --branch main --label delta --skipCheckout", + "test:e2e:dev": "node tests/e2e/testRunner.js --development --skipCheckout --config ./config.dev.js --buildMode skip --skipInstallDeps", + "test:e2e:main": "node tests/e2e/testRunner.js --development --skipCheckout", + "test:e2e:delta": "node tests/e2e/testRunner.js --development --label delta --skipCheckout --skipInstallDeps", "test:e2e:compare": "node tests/e2e/merge.js", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", @@ -58,7 +59,7 @@ "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1" }, "dependencies": { - "@expensify/react-native-web": "0.18.15", + "@dotlottie/react-player": "^1.6.3", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", "@formatjs/intl-listformat": "^7.2.2", @@ -69,6 +70,7 @@ "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.0.1", "@kie/mock-github": "^1.0.0", + "@lottiefiles/react-lottie-player": "^3.5.3", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "^1.17.10", @@ -109,7 +111,7 @@ "jest-expo": "^49.0.0", "jest-when": "^3.5.2", "lodash": "4.17.21", - "lottie-react-native": "^6.3.1", + "lottie-react-native": "^6.4.0", "mapbox-gl": "^2.15.0", "moment": "^2.29.4", "moment-timezone": "^0.5.31", @@ -145,7 +147,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.100", + "react-native-onyx": "1.0.111", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -163,8 +165,8 @@ "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "^3.6.0", "react-native-vision-camera": "^2.16.2", + "react-native-web": "^0.19.9", "react-native-web-linear-gradient": "^1.1.2", - "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", diff --git a/patches/eslint-plugin-react-native-a11y+3.3.0.patch b/patches/eslint-plugin-react-native-a11y+3.3.0.patch new file mode 100644 index 000000000000..fe5998118afa --- /dev/null +++ b/patches/eslint-plugin-react-native-a11y+3.3.0.patch @@ -0,0 +1,59 @@ +diff --git a/node_modules/eslint-plugin-react-native-a11y/__tests__/src/rules/has-valid-accessibility-descriptors-test.js b/node_modules/eslint-plugin-react-native-a11y/__tests__/src/rules/has-valid-accessibility-descriptors-test.js +index 9ecf8b1..fef94dd 100644 +--- a/node_modules/eslint-plugin-react-native-a11y/__tests__/src/rules/has-valid-accessibility-descriptors-test.js ++++ b/node_modules/eslint-plugin-react-native-a11y/__tests__/src/rules/has-valid-accessibility-descriptors-test.js +@@ -20,7 +20,7 @@ const ruleTester = new RuleTester(); + + const expectedError = { + message: +- 'Missing a11y props. Expected one of: accessibilityRole OR BOTH accessibilityLabel + accessibilityHint OR BOTH accessibilityActions + onAccessibilityAction', ++ 'Missing a11y props. Expected one of: accessibilityRole OR role OR BOTH accessibilityLabel + accessibilityHint OR BOTH accessibilityActions + onAccessibilityAction', + type: 'JSXOpeningElement', + }; + +@@ -29,6 +29,11 @@ ruleTester.run('has-valid-accessibility-descriptors', rule, { + { + code: ';', + }, ++ { ++ code: ` ++ Back ++ `, ++ }, + { + code: ` + Back +diff --git a/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-descriptors.js b/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-descriptors.js +index 99deb91..555ebd9 100644 +--- a/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-descriptors.js ++++ b/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-descriptors.js +@@ -16,7 +16,7 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de + // ---------------------------------------------------------------------------- + // Rule Definition + // ---------------------------------------------------------------------------- +-var errorMessage = 'Missing a11y props. Expected one of: accessibilityRole OR BOTH accessibilityLabel + accessibilityHint OR BOTH accessibilityActions + onAccessibilityAction'; ++var errorMessage = 'Missing a11y props. Expected one of: accessibilityRole OR role OR BOTH accessibilityLabel + accessibilityHint OR BOTH accessibilityActions + onAccessibilityAction'; + var schema = (0, _schemas.generateObjSchema)(); + + var hasSpreadProps = function hasSpreadProps(attributes) { +@@ -35,7 +35,7 @@ module.exports = { + return { + JSXOpeningElement: function JSXOpeningElement(node) { + if ((0, _isTouchable.default)(node, context) || (0, _jsxAstUtils.elementType)(node) === 'TextInput') { +- if (!(0, _jsxAstUtils.hasAnyProp)(node.attributes, ['accessibilityRole', 'accessibilityLabel', 'accessibilityActions', 'accessible']) && !hasSpreadProps(node.attributes)) { ++ if (!(0, _jsxAstUtils.hasAnyProp)(node.attributes, ['role', 'accessibilityRole', 'accessibilityLabel', 'accessibilityActions', 'accessible']) && !hasSpreadProps(node.attributes)) { + context.report({ + node, + message: errorMessage, +diff --git a/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-role.js b/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-role.js +index fe74702..fa6bdaa 100644 +--- a/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-role.js ++++ b/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-role.js +@@ -13,5 +13,5 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de + // Rule Definition + // ---------------------------------------------------------------------------- + var errorMessage = 'accessibilityRole must be one of defined values'; +-var validValues = ['togglebutton', 'adjustable', 'alert', 'button', 'checkbox', 'combobox', 'header', 'image', 'imagebutton', 'keyboardkey', 'link', 'menu', 'menubar', 'menuitem', 'none', 'progressbar', 'radio', 'radiogroup', 'scrollbar', 'search', 'spinbutton', 'summary', 'switch', 'tab', 'tabbar', 'tablist', 'text', 'timer', 'list', 'toolbar']; ++var validValues = ['img', 'img button', 'img link', 'togglebutton', 'adjustable', 'alert', 'button', 'checkbox', 'combobox', 'header', 'image', 'imagebutton', 'keyboardkey', 'link', 'menu', 'menubar', 'menuitem', 'none', 'progressbar', 'radio', 'radiogroup', 'scrollbar', 'search', 'spinbutton', 'summary', 'switch', 'tab', 'tabbar', 'tablist', 'text', 'timer', 'list', 'toolbar']; + module.exports = (0, _validProp.default)('accessibilityRole', validValues, errorMessage); +\ No newline at end of file diff --git a/patches/react-native-modal+13.0.1.patch b/patches/react-native-modal+13.0.1.patch index 049a7a09d16a..5bfb2cc5f0b0 100644 --- a/patches/react-native-modal+13.0.1.patch +++ b/patches/react-native-modal+13.0.1.patch @@ -11,7 +11,7 @@ index b63bcfc..bd6419e 100644 buildPanResponder: () => void; getAccDistancePerDirection: (gestureState: PanResponderGestureState) => number; diff --git a/node_modules/react-native-modal/dist/modal.js b/node_modules/react-native-modal/dist/modal.js -index 80f4e75..a88a2ca 100644 +index 80f4e75..3ba8b8c 100644 --- a/node_modules/react-native-modal/dist/modal.js +++ b/node_modules/react-native-modal/dist/modal.js @@ -75,6 +75,13 @@ export class ReactNativeModal extends React.Component { @@ -28,23 +28,27 @@ index 80f4e75..a88a2ca 100644 this.shouldPropagateSwipe = (evt, gestureState) => { return typeof this.props.propagateSwipe === 'function' ? this.props.propagateSwipe(evt, gestureState) -@@ -454,9 +461,15 @@ export class ReactNativeModal extends React.Component { +@@ -453,10 +460,18 @@ export class ReactNativeModal extends React.Component { + if (this.state.isVisible) { this.open(); } - BackHandler.addEventListener('hardwareBackPress', this.onBackButtonPress); + if (Platform.OS === 'web') { + document?.body?.addEventListener?.('keyup', this.handleEscape, true); ++ return; + } + BackHandler.addEventListener('hardwareBackPress', this.onBackButtonPress); } componentWillUnmount() { - BackHandler.removeEventListener('hardwareBackPress', this.onBackButtonPress); +- BackHandler.removeEventListener('hardwareBackPress', this.onBackButtonPress); + if (Platform.OS === 'web') { + document?.body?.removeEventListener?.('keyup', this.handleEscape, true); ++ } else { ++ BackHandler.removeEventListener('hardwareBackPress', this.onBackButtonPress); + } if (this.didUpdateDimensionsEmitter) { this.didUpdateDimensionsEmitter.remove(); } -@@ -525,7 +538,7 @@ export class ReactNativeModal extends React.Component { +@@ -525,7 +540,7 @@ export class ReactNativeModal extends React.Component { } return (React.createElement(Modal, Object.assign({ transparent: true, animationType: 'none', visible: this.state.isVisible, onRequestClose: onBackButtonPress }, otherProps), this.makeBackdrop(), diff --git a/patches/react-native-web+0.19.9+001+initial.patch b/patches/react-native-web+0.19.9+001+initial.patch new file mode 100644 index 000000000000..d88ef83d4bcd --- /dev/null +++ b/patches/react-native-web+0.19.9+001+initial.patch @@ -0,0 +1,286 @@ +diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +index c879838..288316c 100644 +--- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js ++++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +@@ -117,6 +117,14 @@ function findLastWhere(arr, predicate) { + * + */ + class VirtualizedList extends StateSafePureComponent { ++ pushOrUnshift(input, item) { ++ if (this.props.inverted) { ++ input.unshift(item); ++ } else { ++ input.push(item); ++ } ++ } ++ + // scrollToEnd may be janky without getItemLayout prop + scrollToEnd(params) { + var animated = params ? params.animated : true; +@@ -350,6 +358,7 @@ class VirtualizedList extends StateSafePureComponent { + }; + this._defaultRenderScrollComponent = props => { + var onRefresh = props.onRefresh; ++ var inversionStyle = this.props.inverted ? this.props.horizontal ? styles.rowReverse : styles.columnReverse : null; + if (this._isNestedWithSameOrientation()) { + // $FlowFixMe[prop-missing] - Typing ReactNativeComponent revealed errors + return /*#__PURE__*/React.createElement(View, props); +@@ -367,13 +376,16 @@ class VirtualizedList extends StateSafePureComponent { + refreshing: props.refreshing, + onRefresh: onRefresh, + progressViewOffset: props.progressViewOffset +- }) : props.refreshControl ++ }) : props.refreshControl, ++ contentContainerStyle: [inversionStyle, this.props.contentContainerStyle] + })) + ); + } else { + // $FlowFixMe[prop-missing] Invalid prop usage + // $FlowFixMe[incompatible-use] +- return /*#__PURE__*/React.createElement(ScrollView, props); ++ return /*#__PURE__*/React.createElement(ScrollView, _extends({}, props, { ++ contentContainerStyle: [inversionStyle, this.props.contentContainerStyle] ++ })); + } + }; + this._onCellLayout = (e, cellKey, index) => { +@@ -683,7 +695,7 @@ class VirtualizedList extends StateSafePureComponent { + onViewableItemsChanged = _this$props3.onViewableItemsChanged, + viewabilityConfig = _this$props3.viewabilityConfig; + if (onViewableItemsChanged) { +- this._viewabilityTuples.push({ ++ this.pushOrUnshift(this._viewabilityTuples, { + viewabilityHelper: new ViewabilityHelper(viewabilityConfig), + onViewableItemsChanged: onViewableItemsChanged + }); +@@ -937,10 +949,10 @@ class VirtualizedList extends StateSafePureComponent { + var key = _this._keyExtractor(item, ii, _this.props); + _this._indicesToKeys.set(ii, key); + if (stickyIndicesFromProps.has(ii + stickyOffset)) { +- stickyHeaderIndices.push(cells.length); ++ _this.pushOrUnshift(stickyHeaderIndices, cells.length); + } + var shouldListenForLayout = getItemLayout == null || debug || _this._fillRateHelper.enabled(); +- cells.push( /*#__PURE__*/React.createElement(CellRenderer, _extends({ ++ _this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(CellRenderer, _extends({ + CellRendererComponent: CellRendererComponent, + ItemSeparatorComponent: ii < end ? ItemSeparatorComponent : undefined, + ListItemComponent: ListItemComponent, +@@ -1012,14 +1024,14 @@ class VirtualizedList extends StateSafePureComponent { + // 1. Add cell for ListHeaderComponent + if (ListHeaderComponent) { + if (stickyIndicesFromProps.has(0)) { +- stickyHeaderIndices.push(0); ++ this.pushOrUnshift(stickyHeaderIndices, 0); + } + var _element = /*#__PURE__*/React.isValidElement(ListHeaderComponent) ? ListHeaderComponent : + /*#__PURE__*/ + // $FlowFixMe[not-a-component] + // $FlowFixMe[incompatible-type-arg] + React.createElement(ListHeaderComponent, null); +- cells.push( /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { ++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { + cellKey: this._getCellKey() + '-header', + key: "$header" + }, /*#__PURE__*/React.createElement(View, { +@@ -1038,7 +1050,7 @@ class VirtualizedList extends StateSafePureComponent { + // $FlowFixMe[not-a-component] + // $FlowFixMe[incompatible-type-arg] + React.createElement(ListEmptyComponent, null); +- cells.push( /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { ++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { + cellKey: this._getCellKey() + '-empty', + key: "$empty" + }, /*#__PURE__*/React.cloneElement(_element2, { +@@ -1077,7 +1089,7 @@ class VirtualizedList extends StateSafePureComponent { + var firstMetrics = this.__getFrameMetricsApprox(section.first, this.props); + var lastMetrics = this.__getFrameMetricsApprox(last, this.props); + var spacerSize = lastMetrics.offset + lastMetrics.length - firstMetrics.offset; +- cells.push( /*#__PURE__*/React.createElement(View, { ++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(View, { + key: "$spacer-" + section.first, + style: { + [spacerKey]: spacerSize +@@ -1100,7 +1112,7 @@ class VirtualizedList extends StateSafePureComponent { + // $FlowFixMe[not-a-component] + // $FlowFixMe[incompatible-type-arg] + React.createElement(ListFooterComponent, null); +- cells.push( /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { ++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { + cellKey: this._getFooterCellKey(), + key: "$footer" + }, /*#__PURE__*/React.createElement(View, { +@@ -1266,7 +1278,7 @@ class VirtualizedList extends StateSafePureComponent { + * suppresses an error found when Flow v0.68 was deployed. To see the + * error delete this comment and run Flow. */ + if (frame.inLayout) { +- framesInLayout.push(frame); ++ this.pushOrUnshift(framesInLayout, frame); + } + } + var windowTop = this.__getFrameMetricsApprox(this.state.cellsAroundViewport.first, this.props).offset; +@@ -1452,6 +1464,12 @@ var styles = StyleSheet.create({ + left: 0, + borderColor: 'red', + borderWidth: 2 ++ }, ++ rowReverse: { ++ flexDirection: 'row-reverse' ++ }, ++ columnReverse: { ++ flexDirection: 'column-reverse' + } + }); + export default VirtualizedList; +\ No newline at end of file +diff --git a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js +index c7d68bb..46b3fc9 100644 +--- a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js ++++ b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js +@@ -167,6 +167,14 @@ function findLastWhere( + class VirtualizedList extends StateSafePureComponent { + static contextType: typeof VirtualizedListContext = VirtualizedListContext; + ++ pushOrUnshift(input: Array, item: Item) { ++ if (this.props.inverted) { ++ input.unshift(item) ++ } else { ++ input.push(item) ++ } ++ } ++ + // scrollToEnd may be janky without getItemLayout prop + scrollToEnd(params?: ?{animated?: ?boolean, ...}) { + const animated = params ? params.animated : true; +@@ -438,7 +446,7 @@ class VirtualizedList extends StateSafePureComponent { + } else { + const {onViewableItemsChanged, viewabilityConfig} = this.props; + if (onViewableItemsChanged) { +- this._viewabilityTuples.push({ ++ this.pushOrUnshift(this._viewabilityTuples, { + viewabilityHelper: new ViewabilityHelper(viewabilityConfig), + onViewableItemsChanged: onViewableItemsChanged, + }); +@@ -814,13 +822,13 @@ class VirtualizedList extends StateSafePureComponent { + + this._indicesToKeys.set(ii, key); + if (stickyIndicesFromProps.has(ii + stickyOffset)) { +- stickyHeaderIndices.push(cells.length); ++ this.pushOrUnshift(stickyHeaderIndices, (cells.length)); + } + + const shouldListenForLayout = + getItemLayout == null || debug || this._fillRateHelper.enabled(); + +- cells.push( ++ this.pushOrUnshift(cells, + { + // 1. Add cell for ListHeaderComponent + if (ListHeaderComponent) { + if (stickyIndicesFromProps.has(0)) { +- stickyHeaderIndices.push(0); ++ this.pushOrUnshift(stickyHeaderIndices, 0); + } + const element = React.isValidElement(ListHeaderComponent) ? ( + ListHeaderComponent +@@ -932,7 +940,7 @@ class VirtualizedList extends StateSafePureComponent { + // $FlowFixMe[incompatible-type-arg] + + ); +- cells.push( ++ this.pushOrUnshift(cells, + +@@ -963,7 +971,7 @@ class VirtualizedList extends StateSafePureComponent { + // $FlowFixMe[incompatible-type-arg] + + )): any); +- cells.push( ++ this.pushOrUnshift(cells, + +@@ -1017,7 +1025,7 @@ class VirtualizedList extends StateSafePureComponent { + const lastMetrics = this.__getFrameMetricsApprox(last, this.props); + const spacerSize = + lastMetrics.offset + lastMetrics.length - firstMetrics.offset; +- cells.push( ++ this.pushOrUnshift(cells, + { + // $FlowFixMe[incompatible-type-arg] + + ); +- cells.push( ++ this.pushOrUnshift(cells, + +@@ -1246,6 +1254,12 @@ class VirtualizedList extends StateSafePureComponent { + * LTI update could not be added via codemod */ + _defaultRenderScrollComponent = props => { + const onRefresh = props.onRefresh; ++ const inversionStyle = this.props.inverted ++ ? this.props.horizontal ++ ? styles.rowReverse ++ : styles.columnReverse ++ : null; ++ + if (this._isNestedWithSameOrientation()) { + // $FlowFixMe[prop-missing] - Typing ReactNativeComponent revealed errors + return ; +@@ -1273,12 +1287,24 @@ class VirtualizedList extends StateSafePureComponent { + props.refreshControl + ) + } ++ contentContainerStyle={[ ++ inversionStyle, ++ this.props.contentContainerStyle, ++ ]} + /> + ); + } else { + // $FlowFixMe[prop-missing] Invalid prop usage + // $FlowFixMe[incompatible-use] +- return ; ++ return ( ++ ++ ); + } + }; + +@@ -1432,7 +1458,7 @@ class VirtualizedList extends StateSafePureComponent { + * suppresses an error found when Flow v0.68 was deployed. To see the + * error delete this comment and run Flow. */ + if (frame.inLayout) { +- framesInLayout.push(frame); ++ this.pushOrUnshift(framesInLayout, frame); + } + } + const windowTop = this.__getFrameMetricsApprox( +@@ -2044,6 +2070,12 @@ const styles = StyleSheet.create({ + borderColor: 'red', + borderWidth: 2, + }, ++ rowReverse: { ++ flexDirection: 'row-reverse', ++ }, ++ columnReverse: { ++ flexDirection: 'column-reverse', ++ }, + }); + + export default VirtualizedList; +\ No newline at end of file diff --git a/patches/react-native-web+0.19.9+002+fix-mvcp.patch b/patches/react-native-web+0.19.9+002+fix-mvcp.patch new file mode 100644 index 000000000000..afd681bba3b0 --- /dev/null +++ b/patches/react-native-web+0.19.9+002+fix-mvcp.patch @@ -0,0 +1,687 @@ +diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +index a6fe142..faeb323 100644 +--- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js ++++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +@@ -293,7 +293,7 @@ class VirtualizedList extends StateSafePureComponent { + // $FlowFixMe[missing-local-annot] + + constructor(_props) { +- var _this$props$updateCel; ++ var _this$props$updateCel, _this$props$maintainV, _this$props$maintainV2; + super(_props); + this._getScrollMetrics = () => { + return this._scrollMetrics; +@@ -532,6 +532,11 @@ class VirtualizedList extends StateSafePureComponent { + visibleLength, + zoomScale + }; ++ if (this.state.pendingScrollUpdateCount > 0) { ++ this.setState(state => ({ ++ pendingScrollUpdateCount: state.pendingScrollUpdateCount - 1 ++ })); ++ } + this._updateViewableItems(this.props, this.state.cellsAroundViewport); + if (!this.props) { + return; +@@ -581,7 +586,7 @@ class VirtualizedList extends StateSafePureComponent { + this._updateCellsToRender = () => { + this._updateViewableItems(this.props, this.state.cellsAroundViewport); + this.setState((state, props) => { +- var cellsAroundViewport = this._adjustCellsAroundViewport(props, state.cellsAroundViewport); ++ var cellsAroundViewport = this._adjustCellsAroundViewport(props, state.cellsAroundViewport, state.pendingScrollUpdateCount); + var renderMask = VirtualizedList._createRenderMask(props, cellsAroundViewport, this._getNonViewportRenderRegions(props)); + if (cellsAroundViewport.first === state.cellsAroundViewport.first && cellsAroundViewport.last === state.cellsAroundViewport.last && renderMask.equals(state.renderMask)) { + return null; +@@ -601,7 +606,7 @@ class VirtualizedList extends StateSafePureComponent { + return { + index, + item, +- key: this._keyExtractor(item, index, props), ++ key: VirtualizedList._keyExtractor(item, index, props), + isViewable + }; + }; +@@ -633,12 +638,10 @@ class VirtualizedList extends StateSafePureComponent { + }; + this._getFrameMetrics = (index, props) => { + var data = props.data, +- getItem = props.getItem, + getItemCount = props.getItemCount, + getItemLayout = props.getItemLayout; + invariant(index >= 0 && index < getItemCount(data), 'Tried to get frame for out of range index ' + index); +- var item = getItem(data, index); +- var frame = this._frames[this._keyExtractor(item, index, props)]; ++ var frame = this._frames[VirtualizedList._getItemKey(props, index)]; + if (!frame || frame.index !== index) { + if (getItemLayout) { + /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment +@@ -662,7 +665,7 @@ class VirtualizedList extends StateSafePureComponent { + + // The last cell we rendered may be at a new index. Bail if we don't know + // where it is. +- if (focusedCellIndex >= itemCount || this._keyExtractor(props.getItem(props.data, focusedCellIndex), focusedCellIndex, props) !== this._lastFocusedCellKey) { ++ if (focusedCellIndex >= itemCount || VirtualizedList._getItemKey(props, focusedCellIndex) !== this._lastFocusedCellKey) { + return []; + } + var first = focusedCellIndex; +@@ -702,9 +705,15 @@ class VirtualizedList extends StateSafePureComponent { + } + } + var initialRenderRegion = VirtualizedList._initialRenderRegion(_props); ++ var minIndexForVisible = (_this$props$maintainV = (_this$props$maintainV2 = this.props.maintainVisibleContentPosition) == null ? void 0 : _this$props$maintainV2.minIndexForVisible) !== null && _this$props$maintainV !== void 0 ? _this$props$maintainV : 0; + this.state = { + cellsAroundViewport: initialRenderRegion, +- renderMask: VirtualizedList._createRenderMask(_props, initialRenderRegion) ++ renderMask: VirtualizedList._createRenderMask(_props, initialRenderRegion), ++ firstVisibleItemKey: this.props.getItemCount(this.props.data) > minIndexForVisible ? VirtualizedList._getItemKey(this.props, minIndexForVisible) : null, ++ // When we have a non-zero initialScrollIndex, we will receive a ++ // scroll event later so this will prevent the window from updating ++ // until we get a valid offset. ++ pendingScrollUpdateCount: this.props.initialScrollIndex != null && this.props.initialScrollIndex > 0 ? 1 : 0 + }; + + // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller. +@@ -715,7 +724,7 @@ class VirtualizedList extends StateSafePureComponent { + var clientLength = this.props.horizontal ? ev.target.clientWidth : ev.target.clientHeight; + var isEventTargetScrollable = scrollLength > clientLength; + var delta = this.props.horizontal ? ev.deltaX || ev.wheelDeltaX : ev.deltaY || ev.wheelDeltaY; +- var leftoverDelta = delta; ++ var leftoverDelta = delta * 0.5; + if (isEventTargetScrollable) { + leftoverDelta = delta < 0 ? Math.min(delta + scrollOffset, 0) : Math.max(delta - (scrollLength - clientLength - scrollOffset), 0); + } +@@ -760,6 +769,26 @@ class VirtualizedList extends StateSafePureComponent { + } + } + } ++ static _findItemIndexWithKey(props, key, hint) { ++ var itemCount = props.getItemCount(props.data); ++ if (hint != null && hint >= 0 && hint < itemCount) { ++ var curKey = VirtualizedList._getItemKey(props, hint); ++ if (curKey === key) { ++ return hint; ++ } ++ } ++ for (var ii = 0; ii < itemCount; ii++) { ++ var _curKey = VirtualizedList._getItemKey(props, ii); ++ if (_curKey === key) { ++ return ii; ++ } ++ } ++ return null; ++ } ++ static _getItemKey(props, index) { ++ var item = props.getItem(props.data, index); ++ return VirtualizedList._keyExtractor(item, index, props); ++ } + static _createRenderMask(props, cellsAroundViewport, additionalRegions) { + var itemCount = props.getItemCount(props.data); + invariant(cellsAroundViewport.first >= 0 && cellsAroundViewport.last >= cellsAroundViewport.first - 1 && cellsAroundViewport.last < itemCount, "Invalid cells around viewport \"[" + cellsAroundViewport.first + ", " + cellsAroundViewport.last + "]\" was passed to VirtualizedList._createRenderMask"); +@@ -808,7 +837,7 @@ class VirtualizedList extends StateSafePureComponent { + } + } + } +- _adjustCellsAroundViewport(props, cellsAroundViewport) { ++ _adjustCellsAroundViewport(props, cellsAroundViewport, pendingScrollUpdateCount) { + var data = props.data, + getItemCount = props.getItemCount; + var onEndReachedThreshold = onEndReachedThresholdOrDefault(props.onEndReachedThreshold); +@@ -831,17 +860,9 @@ class VirtualizedList extends StateSafePureComponent { + last: Math.min(cellsAroundViewport.last + renderAhead, getItemCount(data) - 1) + }; + } else { +- // If we have a non-zero initialScrollIndex and run this before we've scrolled, +- // we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex. +- // So let's wait until we've scrolled the view to the right place. And until then, +- // we will trust the initialScrollIndex suggestion. +- +- // Thus, we want to recalculate the windowed render limits if any of the following hold: +- // - initialScrollIndex is undefined or is 0 +- // - initialScrollIndex > 0 AND scrolling is complete +- // - initialScrollIndex > 0 AND the end of the list is visible (this handles the case +- // where the list is shorter than the visible area) +- if (props.initialScrollIndex && !this._scrollMetrics.offset && Math.abs(distanceFromEnd) >= Number.EPSILON) { ++ // If we have a pending scroll update, we should not adjust the render window as it ++ // might override the correct window. ++ if (pendingScrollUpdateCount > 0) { + return cellsAroundViewport.last >= getItemCount(data) ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) : cellsAroundViewport; + } + newCellsAroundViewport = computeWindowedRenderLimits(props, maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), windowSizeOrDefault(props.windowSize), cellsAroundViewport, this.__getFrameMetricsApprox, this._scrollMetrics); +@@ -914,16 +935,36 @@ class VirtualizedList extends StateSafePureComponent { + } + } + static getDerivedStateFromProps(newProps, prevState) { ++ var _newProps$maintainVis, _newProps$maintainVis2; + // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make + // sure we're rendering a reasonable range here. + var itemCount = newProps.getItemCount(newProps.data); + if (itemCount === prevState.renderMask.numCells()) { + return prevState; + } +- var constrainedCells = VirtualizedList._constrainToItemCount(prevState.cellsAroundViewport, newProps); ++ var maintainVisibleContentPositionAdjustment = null; ++ var prevFirstVisibleItemKey = prevState.firstVisibleItemKey; ++ var minIndexForVisible = (_newProps$maintainVis = (_newProps$maintainVis2 = newProps.maintainVisibleContentPosition) == null ? void 0 : _newProps$maintainVis2.minIndexForVisible) !== null && _newProps$maintainVis !== void 0 ? _newProps$maintainVis : 0; ++ var newFirstVisibleItemKey = newProps.getItemCount(newProps.data) > minIndexForVisible ? VirtualizedList._getItemKey(newProps, minIndexForVisible) : null; ++ if (newProps.maintainVisibleContentPosition != null && prevFirstVisibleItemKey != null && newFirstVisibleItemKey != null) { ++ if (newFirstVisibleItemKey !== prevFirstVisibleItemKey) { ++ // Fast path if items were added at the start of the list. ++ var hint = itemCount - prevState.renderMask.numCells() + minIndexForVisible; ++ var firstVisibleItemIndex = VirtualizedList._findItemIndexWithKey(newProps, prevFirstVisibleItemKey, hint); ++ maintainVisibleContentPositionAdjustment = firstVisibleItemIndex != null ? firstVisibleItemIndex - minIndexForVisible : null; ++ } else { ++ maintainVisibleContentPositionAdjustment = null; ++ } ++ } ++ var constrainedCells = VirtualizedList._constrainToItemCount(maintainVisibleContentPositionAdjustment != null ? { ++ first: prevState.cellsAroundViewport.first + maintainVisibleContentPositionAdjustment, ++ last: prevState.cellsAroundViewport.last + maintainVisibleContentPositionAdjustment ++ } : prevState.cellsAroundViewport, newProps); + return { + cellsAroundViewport: constrainedCells, +- renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells) ++ renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), ++ firstVisibleItemKey: newFirstVisibleItemKey, ++ pendingScrollUpdateCount: maintainVisibleContentPositionAdjustment != null ? prevState.pendingScrollUpdateCount + 1 : prevState.pendingScrollUpdateCount + }; + } + _pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, first, last, inversionStyle) { +@@ -946,7 +987,7 @@ class VirtualizedList extends StateSafePureComponent { + last = Math.min(end, last); + var _loop = function _loop() { + var item = getItem(data, ii); +- var key = _this._keyExtractor(item, ii, _this.props); ++ var key = VirtualizedList._keyExtractor(item, ii, _this.props); + _this._indicesToKeys.set(ii, key); + if (stickyIndicesFromProps.has(ii + stickyOffset)) { + _this.pushOrUnshift(stickyHeaderIndices, cells.length); +@@ -981,20 +1022,23 @@ class VirtualizedList extends StateSafePureComponent { + } + static _constrainToItemCount(cells, props) { + var itemCount = props.getItemCount(props.data); +- var last = Math.min(itemCount - 1, cells.last); ++ var lastPossibleCellIndex = itemCount - 1; ++ ++ // Constraining `last` may significantly shrink the window. Adjust `first` ++ // to expand the window if the new `last` results in a new window smaller ++ // than the number of cells rendered per batch. + var maxToRenderPerBatch = maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch); ++ var maxFirst = Math.max(0, lastPossibleCellIndex - maxToRenderPerBatch); + return { +- first: clamp(0, itemCount - 1 - maxToRenderPerBatch, cells.first), +- last ++ first: clamp(0, cells.first, maxFirst), ++ last: Math.min(lastPossibleCellIndex, cells.last) + }; + } + _isNestedWithSameOrientation() { + var nestedContext = this.context; + return !!(nestedContext && !!nestedContext.horizontal === horizontalOrDefault(this.props.horizontal)); + } +- _keyExtractor(item, index, props +- // $FlowFixMe[missing-local-annot] +- ) { ++ static _keyExtractor(item, index, props) { + if (props.keyExtractor != null) { + return props.keyExtractor(item, index); + } +@@ -1034,7 +1078,12 @@ class VirtualizedList extends StateSafePureComponent { + this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { + cellKey: this._getCellKey() + '-header', + key: "$header" +- }, /*#__PURE__*/React.createElement(View, { ++ }, /*#__PURE__*/React.createElement(View ++ // We expect that header component will be a single native view so make it ++ // not collapsable to avoid this view being flattened and make this assumption ++ // no longer true. ++ , { ++ collapsable: false, + onLayout: this._onLayoutHeader, + style: [inversionStyle, this.props.ListHeaderComponentStyle] + }, +@@ -1136,7 +1185,11 @@ class VirtualizedList extends StateSafePureComponent { + // TODO: Android support + invertStickyHeaders: this.props.invertStickyHeaders !== undefined ? this.props.invertStickyHeaders : this.props.inverted, + stickyHeaderIndices, +- style: inversionStyle ? [inversionStyle, this.props.style] : this.props.style ++ style: inversionStyle ? [inversionStyle, this.props.style] : this.props.style, ++ maintainVisibleContentPosition: this.props.maintainVisibleContentPosition != null ? _objectSpread(_objectSpread({}, this.props.maintainVisibleContentPosition), {}, { ++ // Adjust index to account for ListHeaderComponent. ++ minIndexForVisible: this.props.maintainVisibleContentPosition.minIndexForVisible + (this.props.ListHeaderComponent ? 1 : 0) ++ }) : undefined + }); + this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1; + var innerRet = /*#__PURE__*/React.createElement(VirtualizedListContextProvider, { +@@ -1319,8 +1372,12 @@ class VirtualizedList extends StateSafePureComponent { + onStartReached = _this$props8.onStartReached, + onStartReachedThreshold = _this$props8.onStartReachedThreshold, + onEndReached = _this$props8.onEndReached, +- onEndReachedThreshold = _this$props8.onEndReachedThreshold, +- initialScrollIndex = _this$props8.initialScrollIndex; ++ onEndReachedThreshold = _this$props8.onEndReachedThreshold; ++ // If we have any pending scroll updates it means that the scroll metrics ++ // are out of date and we should not call any of the edge reached callbacks. ++ if (this.state.pendingScrollUpdateCount > 0) { ++ return; ++ } + var _this$_scrollMetrics2 = this._scrollMetrics, + contentLength = _this$_scrollMetrics2.contentLength, + visibleLength = _this$_scrollMetrics2.visibleLength, +@@ -1360,16 +1417,10 @@ class VirtualizedList extends StateSafePureComponent { + // and call onStartReached only once for a given content length, + // and only if onEndReached is not being executed + else if (onStartReached != null && this.state.cellsAroundViewport.first === 0 && isWithinStartThreshold && this._scrollMetrics.contentLength !== this._sentStartForContentLength) { +- // On initial mount when using initialScrollIndex the offset will be 0 initially +- // and will trigger an unexpected onStartReached. To avoid this we can use +- // timestamp to differentiate between the initial scroll metrics and when we actually +- // received the first scroll event. +- if (!initialScrollIndex || this._scrollMetrics.timestamp !== 0) { +- this._sentStartForContentLength = this._scrollMetrics.contentLength; +- onStartReached({ +- distanceFromStart +- }); +- } ++ this._sentStartForContentLength = this._scrollMetrics.contentLength; ++ onStartReached({ ++ distanceFromStart ++ }); + } + + // If the user scrolls away from the start or end and back again, +@@ -1424,6 +1475,11 @@ class VirtualizedList extends StateSafePureComponent { + } + } + _updateViewableItems(props, cellsAroundViewport) { ++ // If we have any pending scroll updates it means that the scroll metrics ++ // are out of date and we should not call any of the visibility callbacks. ++ if (this.state.pendingScrollUpdateCount > 0) { ++ return; ++ } + this._viewabilityTuples.forEach(tuple => { + tuple.viewabilityHelper.onUpdate(props, this._scrollMetrics.offset, this._scrollMetrics.visibleLength, this._getFrameMetrics, this._createViewToken, tuple.onViewableItemsChanged, cellsAroundViewport); + }); +diff --git a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js +index d896fb1..f303b31 100644 +--- a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js ++++ b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js +@@ -75,6 +75,10 @@ type ViewabilityHelperCallbackTuple = { + type State = { + renderMask: CellRenderMask, + cellsAroundViewport: {first: number, last: number}, ++ // Used to track items added at the start of the list for maintainVisibleContentPosition. ++ firstVisibleItemKey: ?string, ++ // When > 0 the scroll position available in JS is considered stale and should not be used. ++ pendingScrollUpdateCount: number, + }; + + /** +@@ -455,9 +459,24 @@ class VirtualizedList extends StateSafePureComponent { + + const initialRenderRegion = VirtualizedList._initialRenderRegion(props); + ++ const minIndexForVisible = ++ this.props.maintainVisibleContentPosition?.minIndexForVisible ?? 0; ++ + this.state = { + cellsAroundViewport: initialRenderRegion, + renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion), ++ firstVisibleItemKey: ++ this.props.getItemCount(this.props.data) > minIndexForVisible ++ ? VirtualizedList._getItemKey(this.props, minIndexForVisible) ++ : null, ++ // When we have a non-zero initialScrollIndex, we will receive a ++ // scroll event later so this will prevent the window from updating ++ // until we get a valid offset. ++ pendingScrollUpdateCount: ++ this.props.initialScrollIndex != null && ++ this.props.initialScrollIndex > 0 ++ ? 1 ++ : 0, + }; + + // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller. +@@ -470,7 +489,7 @@ class VirtualizedList extends StateSafePureComponent { + const delta = this.props.horizontal + ? ev.deltaX || ev.wheelDeltaX + : ev.deltaY || ev.wheelDeltaY; +- let leftoverDelta = delta; ++ let leftoverDelta = delta * 5; + if (isEventTargetScrollable) { + leftoverDelta = delta < 0 + ? Math.min(delta + scrollOffset, 0) +@@ -542,6 +561,40 @@ class VirtualizedList extends StateSafePureComponent { + } + } + ++ static _findItemIndexWithKey( ++ props: Props, ++ key: string, ++ hint: ?number, ++ ): ?number { ++ const itemCount = props.getItemCount(props.data); ++ if (hint != null && hint >= 0 && hint < itemCount) { ++ const curKey = VirtualizedList._getItemKey(props, hint); ++ if (curKey === key) { ++ return hint; ++ } ++ } ++ for (let ii = 0; ii < itemCount; ii++) { ++ const curKey = VirtualizedList._getItemKey(props, ii); ++ if (curKey === key) { ++ return ii; ++ } ++ } ++ return null; ++ } ++ ++ static _getItemKey( ++ props: { ++ data: Props['data'], ++ getItem: Props['getItem'], ++ keyExtractor: Props['keyExtractor'], ++ ... ++ }, ++ index: number, ++ ): string { ++ const item = props.getItem(props.data, index); ++ return VirtualizedList._keyExtractor(item, index, props); ++ } ++ + static _createRenderMask( + props: Props, + cellsAroundViewport: {first: number, last: number}, +@@ -625,6 +678,7 @@ class VirtualizedList extends StateSafePureComponent { + _adjustCellsAroundViewport( + props: Props, + cellsAroundViewport: {first: number, last: number}, ++ pendingScrollUpdateCount: number, + ): {first: number, last: number} { + const {data, getItemCount} = props; + const onEndReachedThreshold = onEndReachedThresholdOrDefault( +@@ -656,21 +710,9 @@ class VirtualizedList extends StateSafePureComponent { + ), + }; + } else { +- // If we have a non-zero initialScrollIndex and run this before we've scrolled, +- // we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex. +- // So let's wait until we've scrolled the view to the right place. And until then, +- // we will trust the initialScrollIndex suggestion. +- +- // Thus, we want to recalculate the windowed render limits if any of the following hold: +- // - initialScrollIndex is undefined or is 0 +- // - initialScrollIndex > 0 AND scrolling is complete +- // - initialScrollIndex > 0 AND the end of the list is visible (this handles the case +- // where the list is shorter than the visible area) +- if ( +- props.initialScrollIndex && +- !this._scrollMetrics.offset && +- Math.abs(distanceFromEnd) >= Number.EPSILON +- ) { ++ // If we have a pending scroll update, we should not adjust the render window as it ++ // might override the correct window. ++ if (pendingScrollUpdateCount > 0) { + return cellsAroundViewport.last >= getItemCount(data) + ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) + : cellsAroundViewport; +@@ -779,14 +821,59 @@ class VirtualizedList extends StateSafePureComponent { + return prevState; + } + ++ let maintainVisibleContentPositionAdjustment: ?number = null; ++ const prevFirstVisibleItemKey = prevState.firstVisibleItemKey; ++ const minIndexForVisible = ++ newProps.maintainVisibleContentPosition?.minIndexForVisible ?? 0; ++ const newFirstVisibleItemKey = ++ newProps.getItemCount(newProps.data) > minIndexForVisible ++ ? VirtualizedList._getItemKey(newProps, minIndexForVisible) ++ : null; ++ if ( ++ newProps.maintainVisibleContentPosition != null && ++ prevFirstVisibleItemKey != null && ++ newFirstVisibleItemKey != null ++ ) { ++ if (newFirstVisibleItemKey !== prevFirstVisibleItemKey) { ++ // Fast path if items were added at the start of the list. ++ const hint = ++ itemCount - prevState.renderMask.numCells() + minIndexForVisible; ++ const firstVisibleItemIndex = VirtualizedList._findItemIndexWithKey( ++ newProps, ++ prevFirstVisibleItemKey, ++ hint, ++ ); ++ maintainVisibleContentPositionAdjustment = ++ firstVisibleItemIndex != null ++ ? firstVisibleItemIndex - minIndexForVisible ++ : null; ++ } else { ++ maintainVisibleContentPositionAdjustment = null; ++ } ++ } ++ + const constrainedCells = VirtualizedList._constrainToItemCount( +- prevState.cellsAroundViewport, ++ maintainVisibleContentPositionAdjustment != null ++ ? { ++ first: ++ prevState.cellsAroundViewport.first + ++ maintainVisibleContentPositionAdjustment, ++ last: ++ prevState.cellsAroundViewport.last + ++ maintainVisibleContentPositionAdjustment, ++ } ++ : prevState.cellsAroundViewport, + newProps, + ); + + return { + cellsAroundViewport: constrainedCells, + renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), ++ firstVisibleItemKey: newFirstVisibleItemKey, ++ pendingScrollUpdateCount: ++ maintainVisibleContentPositionAdjustment != null ++ ? prevState.pendingScrollUpdateCount + 1 ++ : prevState.pendingScrollUpdateCount, + }; + } + +@@ -818,11 +905,11 @@ class VirtualizedList extends StateSafePureComponent { + + for (let ii = first; ii <= last; ii++) { + const item = getItem(data, ii); +- const key = this._keyExtractor(item, ii, this.props); ++ const key = VirtualizedList._keyExtractor(item, ii, this.props); + + this._indicesToKeys.set(ii, key); + if (stickyIndicesFromProps.has(ii + stickyOffset)) { +- this.pushOrUnshift(stickyHeaderIndices, (cells.length)); ++ this.pushOrUnshift(stickyHeaderIndices, cells.length); + } + + const shouldListenForLayout = +@@ -861,15 +948,19 @@ class VirtualizedList extends StateSafePureComponent { + props: Props, + ): {first: number, last: number} { + const itemCount = props.getItemCount(props.data); +- const last = Math.min(itemCount - 1, cells.last); ++ const lastPossibleCellIndex = itemCount - 1; + ++ // Constraining `last` may significantly shrink the window. Adjust `first` ++ // to expand the window if the new `last` results in a new window smaller ++ // than the number of cells rendered per batch. + const maxToRenderPerBatch = maxToRenderPerBatchOrDefault( + props.maxToRenderPerBatch, + ); ++ const maxFirst = Math.max(0, lastPossibleCellIndex - maxToRenderPerBatch); + + return { +- first: clamp(0, itemCount - 1 - maxToRenderPerBatch, cells.first), +- last, ++ first: clamp(0, cells.first, maxFirst), ++ last: Math.min(lastPossibleCellIndex, cells.last), + }; + } + +@@ -891,15 +982,14 @@ class VirtualizedList extends StateSafePureComponent { + _getSpacerKey = (isVertical: boolean): string => + isVertical ? 'height' : 'width'; + +- _keyExtractor( ++ static _keyExtractor( + item: Item, + index: number, + props: { + keyExtractor?: ?(item: Item, index: number) => string, + ... + }, +- // $FlowFixMe[missing-local-annot] +- ) { ++ ): string { + if (props.keyExtractor != null) { + return props.keyExtractor(item, index); + } +@@ -945,6 +1035,10 @@ class VirtualizedList extends StateSafePureComponent { + cellKey={this._getCellKey() + '-header'} + key="$header"> + { + style: inversionStyle + ? [inversionStyle, this.props.style] + : this.props.style, ++ maintainVisibleContentPosition: ++ this.props.maintainVisibleContentPosition != null ++ ? { ++ ...this.props.maintainVisibleContentPosition, ++ // Adjust index to account for ListHeaderComponent. ++ minIndexForVisible: ++ this.props.maintainVisibleContentPosition.minIndexForVisible + ++ (this.props.ListHeaderComponent ? 1 : 0), ++ } ++ : undefined, + }; + + this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1; +@@ -1255,11 +1359,10 @@ class VirtualizedList extends StateSafePureComponent { + _defaultRenderScrollComponent = props => { + const onRefresh = props.onRefresh; + const inversionStyle = this.props.inverted +- ? this.props.horizontal +- ? styles.rowReverse +- : styles.columnReverse +- : null; +- ++ ? this.props.horizontal ++ ? styles.rowReverse ++ : styles.columnReverse ++ : null; + if (this._isNestedWithSameOrientation()) { + // $FlowFixMe[prop-missing] - Typing ReactNativeComponent revealed errors + return ; +@@ -1542,8 +1645,12 @@ class VirtualizedList extends StateSafePureComponent { + onStartReachedThreshold, + onEndReached, + onEndReachedThreshold, +- initialScrollIndex, + } = this.props; ++ // If we have any pending scroll updates it means that the scroll metrics ++ // are out of date and we should not call any of the edge reached callbacks. ++ if (this.state.pendingScrollUpdateCount > 0) { ++ return; ++ } + const {contentLength, visibleLength, offset} = this._scrollMetrics; + let distanceFromStart = offset; + let distanceFromEnd = contentLength - visibleLength - offset; +@@ -1595,14 +1702,8 @@ class VirtualizedList extends StateSafePureComponent { + isWithinStartThreshold && + this._scrollMetrics.contentLength !== this._sentStartForContentLength + ) { +- // On initial mount when using initialScrollIndex the offset will be 0 initially +- // and will trigger an unexpected onStartReached. To avoid this we can use +- // timestamp to differentiate between the initial scroll metrics and when we actually +- // received the first scroll event. +- if (!initialScrollIndex || this._scrollMetrics.timestamp !== 0) { +- this._sentStartForContentLength = this._scrollMetrics.contentLength; +- onStartReached({distanceFromStart}); +- } ++ this._sentStartForContentLength = this._scrollMetrics.contentLength; ++ onStartReached({distanceFromStart}); + } + + // If the user scrolls away from the start or end and back again, +@@ -1729,6 +1830,11 @@ class VirtualizedList extends StateSafePureComponent { + visibleLength, + zoomScale, + }; ++ if (this.state.pendingScrollUpdateCount > 0) { ++ this.setState(state => ({ ++ pendingScrollUpdateCount: state.pendingScrollUpdateCount - 1, ++ })); ++ } + this._updateViewableItems(this.props, this.state.cellsAroundViewport); + if (!this.props) { + return; +@@ -1844,6 +1950,7 @@ class VirtualizedList extends StateSafePureComponent { + const cellsAroundViewport = this._adjustCellsAroundViewport( + props, + state.cellsAroundViewport, ++ state.pendingScrollUpdateCount, + ); + const renderMask = VirtualizedList._createRenderMask( + props, +@@ -1874,7 +1981,7 @@ class VirtualizedList extends StateSafePureComponent { + return { + index, + item, +- key: this._keyExtractor(item, index, props), ++ key: VirtualizedList._keyExtractor(item, index, props), + isViewable, + }; + }; +@@ -1935,13 +2042,12 @@ class VirtualizedList extends StateSafePureComponent { + inLayout?: boolean, + ... + } => { +- const {data, getItem, getItemCount, getItemLayout} = props; ++ const {data, getItemCount, getItemLayout} = props; + invariant( + index >= 0 && index < getItemCount(data), + 'Tried to get frame for out of range index ' + index, + ); +- const item = getItem(data, index); +- const frame = this._frames[this._keyExtractor(item, index, props)]; ++ const frame = this._frames[VirtualizedList._getItemKey(props, index)]; + if (!frame || frame.index !== index) { + if (getItemLayout) { + /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment +@@ -1976,11 +2082,8 @@ class VirtualizedList extends StateSafePureComponent { + // where it is. + if ( + focusedCellIndex >= itemCount || +- this._keyExtractor( +- props.getItem(props.data, focusedCellIndex), +- focusedCellIndex, +- props, +- ) !== this._lastFocusedCellKey ++ VirtualizedList._getItemKey(props, focusedCellIndex) !== ++ this._lastFocusedCellKey + ) { + return []; + } +@@ -2021,6 +2124,11 @@ class VirtualizedList extends StateSafePureComponent { + props: FrameMetricProps, + cellsAroundViewport: {first: number, last: number}, + ) { ++ // If we have any pending scroll updates it means that the scroll metrics ++ // are out of date and we should not call any of the visibility callbacks. ++ if (this.state.pendingScrollUpdateCount > 0) { ++ return; ++ } + this._viewabilityTuples.forEach(tuple => { + tuple.viewabilityHelper.onUpdate( + props, diff --git a/patches/react-native-web-lottie+1.4.4.patch b/patches/react-native-web-lottie+1.4.4.patch deleted file mode 100644 index c82c33b5a7fe..000000000000 --- a/patches/react-native-web-lottie+1.4.4.patch +++ /dev/null @@ -1,9 +0,0 @@ -diff --git a/node_modules/react-native-web-lottie/dist/index.js b/node_modules/react-native-web-lottie/dist/index.js -index 7cd6b42..9c2b356 100644 ---- a/node_modules/react-native-web-lottie/dist/index.js -+++ b/node_modules/react-native-web-lottie/dist/index.js -@@ -1 +1 @@ --var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");var _interopRequireWildcard=require("@babel/runtime/helpers/interopRequireWildcard");Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _extends2=_interopRequireDefault(require("@babel/runtime/helpers/extends"));var _classCallCheck2=_interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));var _createClass2=_interopRequireDefault(require("@babel/runtime/helpers/createClass"));var _possibleConstructorReturn2=_interopRequireDefault(require("@babel/runtime/helpers/possibleConstructorReturn"));var _getPrototypeOf3=_interopRequireDefault(require("@babel/runtime/helpers/getPrototypeOf"));var _inherits2=_interopRequireDefault(require("@babel/runtime/helpers/inherits"));var _react=_interopRequireWildcard(require("react"));var _reactDom=_interopRequireDefault(require("react-dom"));var _View=_interopRequireDefault(require("react-native-web/dist/exports/View"));var _lottieWeb=_interopRequireDefault(require("lottie-web"));var _jsxFileName="/Users/louislagrange/Documents/Projets/react-native-web-community/react-native-web-lottie/src/index.js";var Animation=function(_PureComponent){(0,_inherits2.default)(Animation,_PureComponent);function Animation(){var _getPrototypeOf2;var _this;(0,_classCallCheck2.default)(this,Animation);for(var _len=arguments.length,args=new Array(_len),_key=0;_key<_len;_key++){args[_key]=arguments[_key];}_this=(0,_possibleConstructorReturn2.default)(this,(_getPrototypeOf2=(0,_getPrototypeOf3.default)(Animation)).call.apply(_getPrototypeOf2,[this].concat(args)));_this.animationDOMNode=null;_this.loadAnimation=function(props){if(_this.anim){_this.anim.destroy();}_this.anim=_lottieWeb.default.loadAnimation({container:_this.animationDOMNode,animationData:props.source,renderer:'svg',loop:props.loop||false,autoplay:props.autoPlay,rendererSettings:props.rendererSettings||{}});if(props.onAnimationFinish){_this.anim.addEventListener('complete',props.onAnimationFinish);}};_this.setAnimationDOMNode=function(ref){return _this.animationDOMNode=_reactDom.default.findDOMNode(ref);};_this.play=function(){if(!_this.anim){return;}for(var _len2=arguments.length,frames=new Array(_len2),_key2=0;_key2<_len2;_key2++){frames[_key2]=arguments[_key2];}_this.anim.playSegments(frames,true);};_this.reset=function(){if(!_this.anim){return;}_this.anim.stop();};return _this;}(0,_createClass2.default)(Animation,[{key:"componentDidMount",value:function componentDidMount(){var _this2=this;this.loadAnimation(this.props);if(typeof this.props.progress==='object'&&this.props.progress._listeners){this.props.progress.addListener(function(progress){var value=progress.value;var frame=value/(1/_this2.anim.getDuration(true));_this2.anim.goToAndStop(frame,true);});}}},{key:"componentWillUnmount",value:function componentWillUnmount(){if(typeof this.props.progress==='object'&&this.props.progress._listeners){this.props.progress.removeAllListeners();}}},{key:"UNSAFE_componentWillReceiveProps",value:function UNSAFE_componentWillReceiveProps(nextProps){if(this.props.source&&nextProps.source&&this.props.source.nm!==nextProps.source.nm){this.loadAnimation(nextProps);}}},{key:"render",value:function render(){return _react.default.createElement(_View.default,{style:this.props.style,ref:this.setAnimationDOMNode,__source:{fileName:_jsxFileName,lineNumber:71}});}}]);return Animation;}(_react.PureComponent);var _default=_react.default.forwardRef(function(props,ref){return _react.default.createElement(Animation,(0,_extends2.default)({},props,{ref:typeof ref=='function'?function(c){return ref(c&&c.anim);}:ref,__source:{fileName:_jsxFileName,lineNumber:76}}));});exports.default=_default; -\ No newline at end of file -+var _interopRequireWildcard=require("@babel/runtime/helpers/interopRequireWildcard");var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _react=_interopRequireWildcard(require("react"));var _reactDom=_interopRequireDefault(require("react-dom"));var _View=_interopRequireDefault(require("react-native-web/dist/exports/View"));var _lottieWeb=_interopRequireDefault(require("lottie-web"));var _jsxFileName="/Users/roryabraham/react-native-web-lottie/src/index.js";function Animation(_ref){var source=_ref.source,_ref$renderer=_ref.renderer,renderer=_ref$renderer===void 0?'svg':_ref$renderer,_ref$loop=_ref.loop,loop=_ref$loop===void 0?false:_ref$loop,_ref$autoPlay=_ref.autoPlay,autoPlay=_ref$autoPlay===void 0?false:_ref$autoPlay,_ref$rendererSettings=_ref.rendererSettings,rendererSettings=_ref$rendererSettings===void 0?{}:_ref$rendererSettings,_ref$style=_ref.style,style=_ref$style===void 0?{}:_ref$style;var nm=source.nm;var anim=(0,_react.useRef)(null);var animationDOMNode=(0,_react.useRef)(null);(0,_react.useEffect)(function(){var _anim$current;(_anim$current=anim.current)==null?void 0:_anim$current.destroy();anim.current=_lottieWeb.default.loadAnimation({container:animationDOMNode.current,animationData:source,renderer:renderer,loop:loop,autoPlay:autoPlay,rendererSettings:rendererSettings});return function(){var _anim$current2;(_anim$current2=anim.current)==null?void 0:_anim$current2.destroy();};},[nm]);return _react.default.createElement(_View.default,{style:style,ref:function ref(r){return animationDOMNode.current=_reactDom.default.findDOMNode(r);},__source:{fileName:_jsxFileName,lineNumber:36}});}var _default=_react.default.memo(Animation);exports.default=_default; -\ No newline at end of file diff --git a/src/CONST.ts b/src/CONST.ts index 155e50a35cde..8cb2bb6c8893 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -50,6 +50,9 @@ const CONST = { // An arbitrary size, but the same minimum as in the PHP layer MIN_SIZE: 240, + + // Allowed extensions for receipts + ALLOWED_RECEIPT_EXTENSIONS: ['jpg', 'jpeg', 'gif', 'png', 'pdf', 'htm', 'html', 'text', 'rtf', 'doc', 'tif', 'tiff', 'msword', 'zip', 'xml', 'message'], }, AUTO_AUTH_STATE: { @@ -144,7 +147,6 @@ const CONST = { DESKTOP: `${ACTIVE_EXPENSIFY_URL}NewExpensify.dmg`, }, DATE: { - MOMENT_FORMAT_STRING: 'YYYY-MM-DD', SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss', FNS_FORMAT_STRING: 'yyyy-MM-dd', LOCAL_TIME_FORMAT: 'h:mm a', @@ -260,6 +262,7 @@ const CONST = { CUSTOM_STATUS: 'customStatus', NEW_DOT_TAGS: 'newDotTags', NEW_DOT_SAML: 'newDotSAML', + VIOLATIONS: 'violations', }, BUTTON_STATES: { DEFAULT: 'default', @@ -866,14 +869,19 @@ const CONST = { RECOVERY_CODE_LENGTH: 8, KEYBOARD_TYPE: { - PHONE_PAD: 'phone-pad', - NUMBER_PAD: 'number-pad', - DECIMAL_PAD: 'decimal-pad', VISIBLE_PASSWORD: 'visible-password', - EMAIL_ADDRESS: 'email-address', ASCII_CAPABLE: 'ascii-capable', + }, + + INPUT_MODE: { + NONE: 'none', + TEXT: 'text', + DECIMAL: 'decimal', + NUMERIC: 'numeric', + TEL: 'tel', + SEARCH: 'search', + EMAIL: 'email', URL: 'url', - DEFAULT: 'default', }, YOUR_LOCATION_TEXT: 'Your Location', @@ -1312,6 +1320,7 @@ const CONST = { TAX_ID: /^\d{9}$/, NON_NUMERIC: /\D/g, + ANY_SPACE: /\s/g, // Extract attachment's source from the data's html string ATTACHMENT_DATA: /(data-expensify-source|data-name)="([^"]+)"/g, @@ -1327,6 +1336,8 @@ const CONST = { SPECIAL_CHAR: /[,/?"{}[\]()&^%;`$=#<>!*]/g, + FIRST_SPACE: /.+?(?=\s)/, + get SPECIAL_CHAR_OR_EMOJI() { return new RegExp(`[~\\n\\s]|(_\\b(?!$))|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu'); }, @@ -2694,13 +2705,13 @@ const CONST = { BUTTON: 'button', LINK: 'link', MENUITEM: 'menuitem', - TEXT: 'text', + TEXT: 'presentation', RADIO: 'radio', - IMAGEBUTTON: 'imagebutton', + IMAGEBUTTON: 'img button', CHECKBOX: 'checkbox', SWITCH: 'switch', - ADJUSTABLE: 'adjustable', - IMAGE: 'image', + ADJUSTABLE: 'slider', + IMAGE: 'img', }, TRANSLATION_KEYS: { ATTACHMENT: 'common.attachment', diff --git a/src/Expensify.js b/src/Expensify.js index b7e3f0f60567..1b692f86a197 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -90,6 +90,8 @@ const defaultProps = { isCheckingPublicRoom: true, }; +const SplashScreenHiddenContext = React.createContext({}); + function Expensify(props) { const appStateChangeListener = useRef(null); const [isNavigationReady, setIsNavigationReady] = useState(false); @@ -105,6 +107,14 @@ function Expensify(props) { }, [props.isCheckingPublicRoom]); const isAuthenticated = useMemo(() => Boolean(lodashGet(props.session, 'authToken', null)), [props.session]); + + const contextValue = useMemo( + () => ({ + isSplashHidden, + }), + [isSplashHidden], + ); + const shouldInit = isNavigationReady && (!isAuthenticated || props.isSidebarLoaded) && hasAttemptedToOpenPublicRoom; const shouldHideSplash = shouldInit && !isSplashHidden; @@ -216,10 +226,12 @@ function Expensify(props) { {hasAttemptedToOpenPublicRoom && ( - + + + )} {shouldHideSplash && } @@ -251,3 +263,5 @@ export default compose( }, }), )(Expensify); + +export {SplashScreenHiddenContext}; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 11c2318672d8..fa72a99b5fa2 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -275,42 +275,72 @@ const ONYXKEYS = { /** List of Form ids */ FORMS: { ADD_DEBIT_CARD_FORM: 'addDebitCardForm', + ADD_DEBIT_CARD_FORM_DRAFT: 'addDebitCardFormDraft', WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', + WORKSPACE_SETTINGS_FORM_DRAFT: 'workspaceSettingsFormDraft', WORKSPACE_RATE_AND_UNIT_FORM: 'workspaceRateAndUnitForm', + WORKSPACE_RATE_AND_UNIT_FORM_DRAFT: 'workspaceRateAndUnitFormDraft', CLOSE_ACCOUNT_FORM: 'closeAccount', + CLOSE_ACCOUNT_FORM_DRAFT: 'closeAccountDraft', PROFILE_SETTINGS_FORM: 'profileSettingsForm', + PROFILE_SETTINGS_FORM_DRAFT: 'profileSettingsFormDraft', DISPLAY_NAME_FORM: 'displayNameForm', + DISPLAY_NAME_FORM_DRAFT: 'displayNameFormDraft', ROOM_NAME_FORM: 'roomNameForm', + ROOM_NAME_FORM_DRAFT: 'roomNameFormDraft', WELCOME_MESSAGE_FORM: 'welcomeMessageForm', + WELCOME_MESSAGE_FORM_DRAFT: 'welcomeMessageFormDraft', LEGAL_NAME_FORM: 'legalNameForm', + LEGAL_NAME_FORM_DRAFT: 'legalNameFormDraft', WORKSPACE_INVITE_MESSAGE_FORM: 'workspaceInviteMessageForm', + WORKSPACE_INVITE_MESSAGE_FORM_DRAFT: 'workspaceInviteMessageFormDraft', DATE_OF_BIRTH_FORM: 'dateOfBirthForm', + DATE_OF_BIRTH_FORM_DRAFT: 'dateOfBirthFormDraft', HOME_ADDRESS_FORM: 'homeAddressForm', + HOME_ADDRESS_FORM_DRAFT: 'homeAddressFormDraft', NEW_ROOM_FORM: 'newRoomForm', + NEW_ROOM_FORM_DRAFT: 'newRoomFormDraft', ROOM_SETTINGS_FORM: 'roomSettingsForm', + ROOM_SETTINGS_FORM_DRAFT: 'roomSettingsFormDraft', NEW_TASK_FORM: 'newTaskForm', + NEW_TASK_FORM_DRAFT: 'newTaskFormDraft', EDIT_TASK_FORM: 'editTaskForm', + EDIT_TASK_FORM_DRAFT: 'editTaskFormDraft', MONEY_REQUEST_DESCRIPTION_FORM: 'moneyRequestDescriptionForm', + MONEY_REQUEST_DESCRIPTION_FORM_DRAFT: 'moneyRequestDescriptionFormDraft', MONEY_REQUEST_MERCHANT_FORM: 'moneyRequestMerchantForm', + MONEY_REQUEST_MERCHANT_FORM_DRAFT: 'moneyRequestMerchantFormDraft', MONEY_REQUEST_AMOUNT_FORM: 'moneyRequestAmountForm', + MONEY_REQUEST_AMOUNT_FORM_DRAFT: 'moneyRequestAmountFormDraft', MONEY_REQUEST_DATE_FORM: 'moneyRequestCreatedForm', + MONEY_REQUEST_DATE_FORM_DRAFT: 'moneyRequestCreatedFormDraft', NEW_CONTACT_METHOD_FORM: 'newContactMethodForm', + NEW_CONTACT_METHOD_FORM_DRAFT: 'newContactMethodFormDraft', WAYPOINT_FORM: 'waypointForm', WAYPOINT_FORM_DRAFT: 'waypointFormDraft', SETTINGS_STATUS_SET_FORM: 'settingsStatusSetForm', + SETTINGS_STATUS_SET_FORM_DRAFT: 'settingsStatusSetFormDraft', SETTINGS_STATUS_CLEAR_AFTER_FORM: 'settingsStatusClearAfterForm', + SETTINGS_STATUS_CLEAR_AFTER_FORM_DRAFT: 'settingsStatusClearAfterFormDraft', SETTINGS_STATUS_SET_CLEAR_AFTER_FORM: 'settingsStatusSetClearAfterForm', + SETTINGS_STATUS_SET_CLEAR_AFTER_FORM_DRAFT: 'settingsStatusSetClearAfterFormDraft', PRIVATE_NOTES_FORM: 'privateNotesForm', + PRIVATE_NOTES_FORM_DRAFT: 'privateNotesFormDraft', I_KNOW_A_TEACHER_FORM: 'iKnowTeacherForm', + I_KNOW_A_TEACHER_FORM_DRAFT: 'iKnowTeacherFormDraft', INTRO_SCHOOL_PRINCIPAL_FORM: 'introSchoolPrincipalForm', + INTRO_SCHOOL_PRINCIPAL_FORM_DRAFT: 'introSchoolPrincipalFormDraft', REPORT_PHYSICAL_CARD_FORM: 'requestPhysicalCardForm', + REPORT_PHYSICAL_CARD_FORM_DRAFT: 'requestPhysicalCardFormDraft', REPORT_VIRTUAL_CARD_FRAUD: 'reportVirtualCardFraudForm', + REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft', }, } as const; type OnyxKeysMap = typeof ONYXKEYS; type OnyxCollectionKey = ValueOf; type OnyxKey = DeepValueOf>; +type OnyxFormKey = ValueOf | OnyxKeysMap['REIMBURSEMENT_ACCOUNT'] | OnyxKeysMap['REIMBURSEMENT_ACCOUNT_DRAFT']; type OnyxValues = { [ONYXKEYS.ACCOUNT]: OnyxTypes.Account; @@ -411,36 +441,68 @@ type OnyxValues = { // Forms [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: OnyxTypes.AddDebitCardForm; + [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM_DRAFT]: OnyxTypes.AddDebitCardForm; [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.DISPLAY_NAME_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.ROOM_NAME_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.ROOM_NAME_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.WELCOME_MESSAGE_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.WELCOME_MESSAGE_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.LEGAL_NAME_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: OnyxTypes.DateOfBirthForm; + [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM_DRAFT]: OnyxTypes.DateOfBirthForm; [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.HOME_ADDRESS_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.NEW_ROOM_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.NEW_ROOM_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.NEW_TASK_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.NEW_TASK_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.EDIT_TASK_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.EDIT_TASK_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.WAYPOINT_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.WAYPOINT_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_AFTER_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_AFTER_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: OnyxTypes.Form; + [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; }; type OnyxKeyValue = OnyxEntry; export default ONYXKEYS; -export type {OnyxKey, OnyxCollectionKey, OnyxValues, OnyxKeyValue}; +export type {OnyxKey, OnyxCollectionKey, OnyxValues, OnyxKeyValue, OnyxFormKey, OnyxKeysMap}; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index bcc4685368cb..864e8934ad88 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2,14 +2,11 @@ import {ValueOf} from 'type-fest'; import CONST from './CONST'; /** - * This is a file containing constants for all of the routes we want to be able to go to + * This is a file containing constants for all the routes we want to be able to go to */ /** - * This is a file containing constants for all of the routes we want to be able to go to - * Returns the URL with an encoded URI component for the backTo param which can be added to the end of URLs - * @param backTo - * @returns + * Builds a URL with an encoded URI component for the `backTo` param which can be added to the end of URLs */ function getUrlWithBackToParam(url: string, backTo?: string): string { const backToParam = backTo ? `${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` : ''; @@ -111,7 +108,10 @@ export default { route: 'settings/profile/personal-details/address/country', getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/personal-details/address/country?country=${country}`, backTo), }, - SETTINGS_CONTACT_METHODS: 'settings/profile/contact-methods', + SETTINGS_CONTACT_METHODS: { + route: 'settings/profile/contact-methods', + getRoute: (backTo?: string) => getUrlWithBackToParam('settings/profile/contact-methods', backTo), + }, SETTINGS_CONTACT_METHOD_DETAILS: { route: 'settings/profile/contact-methods/:contactMethod/details', getRoute: (contactMethod: string) => `settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details`, diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index 7010ab514617..566b6c709423 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -240,7 +240,7 @@ function AddPlaidBankAccount({ /> {bankName} - + ({ - language: preferredLocale, - types: resultTypes, - components: isLimitedToUSA ? 'country:us' : undefined, + language: props.preferredLocale, + types: props.resultTypes, + components: props.isLimitedToUSA ? 'country:us' : undefined, }), - [preferredLocale, resultTypes, isLimitedToUSA], + [props.preferredLocale, props.resultTypes, props.isLimitedToUSA], ); - const shouldShowCurrentLocationButton = canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; + const shouldShowCurrentLocationButton = props.canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; const saveLocationDetails = (autocompleteData, details) => { const addressComponents = details.address_components; @@ -188,7 +169,7 @@ function AddressSearch({ // to this component which don't match the usual properties coming from auto-complete. In that case, only a limited // amount of data massaging needs to happen for what the parent expects to get from this function. if (_.size(details)) { - onPress({ + props.onPress({ address: lodashGet(details, 'description'), lat: lodashGet(details, 'geometry.location.lat', 0), lng: lodashGet(details, 'geometry.location.lng', 0), @@ -275,7 +256,7 @@ function AddressSearch({ // Not all pages define the Address Line 2 field, so in that case we append any additional address details // (e.g. Apt #) to Address Line 1 - if (subpremise && typeof renamedInputKeys.street2 === 'undefined') { + if (subpremise && typeof props.renamedInputKeys.street2 === 'undefined') { values.street += `, ${subpremise}`; } @@ -284,19 +265,19 @@ function AddressSearch({ values.country = country; } - if (inputID) { - _.each(values, (inputValue, key) => { - const inputKey = lodashGet(renamedInputKeys, key, key); + if (props.inputID) { + _.each(values, (value, key) => { + const inputKey = lodashGet(props.renamedInputKeys, key, key); if (!inputKey) { return; } - onInputChange(inputValue, inputKey); + props.onInputChange(value, inputKey); }); } else { - onInputChange(values); + props.onInputChange(values); } - onPress(values); + props.onPress(values); }; /** Gets the user's current location and registers success/error callbacks */ @@ -326,7 +307,7 @@ function AddressSearch({ lng: successData.coords.longitude, address: CONST.YOUR_LOCATION_TEXT, }; - onPress(location); + props.onPress(location); }, (errorData) => { if (!shouldTriggerGeolocationCallbacks.current) { @@ -344,16 +325,16 @@ function AddressSearch({ }; const renderHeaderComponent = () => - predefinedPlaces.length > 0 && ( + props.predefinedPlaces.length > 0 && ( <> {/* This will show current location button in list if there are some recent destinations */} {shouldShowCurrentLocationButton && ( )} - {!value && {translate('common.recentDestinations')}} + {!props.value && {props.translate('common.recentDestinations')}} ); @@ -365,26 +346,6 @@ function AddressSearch({ }; }, []); - const listEmptyComponent = useCallback( - () => - network.isOffline || !isTyping ? null : ( - {translate('common.noResultsFound')} - ), - [isTyping, translate, network.isOffline], - ); - - const listLoader = useCallback( - () => ( - - - - ), - [], - ); - return ( /* * The GooglePlacesAutocomplete component uses a VirtualizedList internally, @@ -411,10 +372,20 @@ function AddressSearch({ fetchDetails suppressDefaultStyles enablePoweredByContainer={false} - predefinedPlaces={predefinedPlaces} - listEmptyComponent={listEmptyComponent} - listLoaderComponent={listLoader} - renderHeaderComponent={renderHeaderComponent} + predefinedPlaces={props.predefinedPlaces} + listEmptyComponent={ + props.network.isOffline || !isTyping ? null : ( + {props.translate('common.noResultsFound')} + ) + } + listLoaderComponent={ + + + + } renderRow={(data) => { const title = data.isPredefinedPlace ? data.name : data.structured_formatting.main_text; const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text; @@ -425,6 +396,7 @@ function AddressSearch({ ); }} + renderHeaderComponent={renderHeaderComponent} onPress={(data, details) => { saveLocationDetails(data, details); setIsTyping(false); @@ -439,31 +411,34 @@ function AddressSearch({ query={query} requestUrl={{ useOnPlatform: 'all', - url: network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), + url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), }} textInputProps={{ InputComp: TextInput, ref: (node) => { - if (!innerRef) { + if (!props.innerRef) { return; } - if (_.isFunction(innerRef)) { - innerRef(node); + if (_.isFunction(props.innerRef)) { + props.innerRef(node); return; } // eslint-disable-next-line no-param-reassign - innerRef.current = node; + props.innerRef.current = node; }, - label, - containerStyles, - errorText, - hint: displayListViewBorder || (predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint, - value, - defaultValue, - inputID, - shouldSaveDraft, + label: props.label, + containerStyles: props.containerStyles, + errorText: props.errorText, + hint: + displayListViewBorder || (props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (props.canUseCurrentLocation && isTyping) + ? undefined + : props.hint, + value: props.value, + defaultValue: props.defaultValue, + inputID: props.inputID, + shouldSaveDraft: props.shouldSaveDraft, onFocus: () => { setIsFocused(true); }, @@ -473,24 +448,24 @@ function AddressSearch({ setIsFocused(false); setIsTyping(false); } - onBlur(); + props.onBlur(); }, autoComplete: 'off', onInputChange: (text) => { setSearchValue(text); setIsTyping(true); - if (inputID) { - onInputChange(text); + if (props.inputID) { + props.onInputChange(text); } else { - onInputChange({street: text}); + props.onInputChange({street: text}); } // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering - if (_.isEmpty(text) && _.isEmpty(predefinedPlaces)) { + if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) { setDisplayListViewBorder(false); } }, - maxLength: maxInputLength, + maxLength: props.maxInputLength, spellCheck: false, }} styles={{ @@ -511,18 +486,17 @@ function AddressSearch({ }} inbetweenCompo={ // We want to show the current location button even if there are no recent destinations - predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? ( + props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? ( ) : ( <> ) } - placeholder="" /> setLocationErrorCode(null)} diff --git a/src/components/AmountTextInput.js b/src/components/AmountTextInput.js index 5899e68bedb3..43fd5e6a1b98 100644 --- a/src/components/AmountTextInput.js +++ b/src/components/AmountTextInput.js @@ -50,11 +50,11 @@ function AmountTextInput(props) { ref={props.forwardedRef} value={props.formattedAmount} placeholder={props.placeholder} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + inputMode={CONST.INPUT_MODE.NUMERIC} blurOnSubmit={false} selection={props.selection} onSelectionChange={props.onSelectionChange} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} onKeyPress={props.onKeyPress} /> ); diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js index fd6c3d358a33..1e2d18bc4691 100644 --- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js +++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js @@ -58,7 +58,7 @@ function BaseAnchorForAttachmentsOnly(props) { onPressOut={props.onPressOut} onLongPress={(event) => showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} accessibilityLabel={fileName} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} > (linkRef = el)} style={StyleSheet.flatten([style, defaultTextStyle])} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.LINK} + role={CONST.ACCESSIBILITY_ROLE.LINK} hrefAttrs={{ rel, target: isEmail || !linkProps.href ? '_self' : target, diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 96d22df5f07b..c7d286a8b539 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -444,16 +444,21 @@ function AttachmentModal(props) { ) : ( Boolean(sourceForAttachmentView) && shouldLoadAttachment && ( - + // We need the following View component on android native + // So that the event will propagate properly and + // the Password protected preview will be shown for pdf attachement we are about to send. + + + ) )} diff --git a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js index 9bef889e61a1..f11bbcc9b187 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js +++ b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js @@ -82,5 +82,6 @@ function CarouselButtons({page, attachments, shouldShowArrows, onBack, onForward CarouselButtons.propTypes = propTypes; CarouselButtons.defaultProps = defaultProps; +CarouselButtons.displayName = 'CarouselButtons'; export default CarouselButtons; diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.js index 2d271aa6d4c4..38f70057be61 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.js @@ -61,8 +61,7 @@ function CarouselItem({item, isFocused, onPress}) { onPress={() => setIsHidden(!isHidden)} > {isHidden ? translate('moderation.revealMessage') : translate('moderation.hideMessage')} @@ -81,7 +80,7 @@ function CarouselItem({item, isFocused, onPress}) { {children} @@ -116,5 +115,6 @@ function CarouselItem({item, isFocused, onPress}) { CarouselItem.propTypes = propTypes; CarouselItem.defaultProps = defaultProps; +CarouselItem.displayName = 'CarouselItem'; export default CarouselItem; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js index 2ded34829a08..7a083d71b591 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js @@ -181,7 +181,9 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI ); } + AttachmentCarouselPage.propTypes = pagePropTypes; AttachmentCarouselPage.defaultProps = defaultProps; +AttachmentCarouselPage.displayName = 'AttachmentCarouselPage'; export default AttachmentCarouselPage; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js index 5bf8b79dae77..0839462d4f23 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js @@ -574,5 +574,6 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc } ImageTransformer.propTypes = imageTransformerPropTypes; ImageTransformer.defaultProps = imageTransformerDefaultProps; +ImageTransformer.displayName = 'ImageTransformer'; export default ImageTransformer; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js index 10f2ae94340a..3a27d80c5509 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js @@ -1,4 +1,3 @@ -/* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; import React from 'react'; import {StyleSheet} from 'react-native'; @@ -19,6 +18,8 @@ function ImageWrapper({children}) { ); } + ImageWrapper.propTypes = imageWrapperPropTypes; +ImageWrapper.displayName = 'ImageWrapper'; export default ImageWrapper; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js index e4659caf24f0..59fd7596f0ad 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js @@ -1,4 +1,3 @@ -/* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; import React, {useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -168,8 +167,10 @@ function AttachmentCarouselPager({ ); } + AttachmentCarouselPager.propTypes = pagerPropTypes; AttachmentCarouselPager.defaultProps = pagerDefaultProps; +AttachmentCarouselPager.displayName = 'AttachmentCarouselPager'; const AttachmentCarouselPagerWithRef = React.forwardRef((props, ref) => ( ); } + AttachmentCarousel.propTypes = propTypes; AttachmentCarousel.defaultProps = defaultProps; +AttachmentCarousel.displayName = 'AttachmentCarousel'; export default compose( withOnyx({ diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js index 2477a084b63b..d96a54b7a6b5 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.js +++ b/src/components/Attachments/AttachmentCarousel/index.native.js @@ -170,6 +170,7 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, } AttachmentCarousel.propTypes = propTypes; AttachmentCarousel.defaultProps = defaultProps; +AttachmentCarousel.displayName = 'AttachmentCarousel'; export default compose( withOnyx({ diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js index 23049915a8d9..d88eb81506ca 100755 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js @@ -27,7 +27,7 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, loadComplete, o onPress={onPress} disabled={loadComplete} style={[styles.flex1, styles.flexRow, styles.alignSelfStretch]} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={file.name || translate('attachmentView.unknownFilename')} > {children} @@ -39,5 +39,6 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, loadComplete, o AttachmentViewImage.propTypes = propTypes; AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps; +AttachmentViewImage.displayName = 'AttachmentViewImage'; export default compose(memo, withLocalize)(AttachmentViewImage); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js index faf2f21c133d..8b29d8d5ba3d 100755 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js @@ -35,7 +35,7 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, isFocused, isUs onPress={onPress} disabled={loadComplete} style={[styles.flex1, styles.flexRow, styles.alignSelfStretch]} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={file.name || translate('attachmentView.unknownFilename')} > {children} @@ -47,5 +47,6 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, isFocused, isUs AttachmentViewImage.propTypes = propTypes; AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps; +AttachmentViewImage.displayName = 'AttachmentViewImage'; export default compose(memo, withLocalize)(AttachmentViewImage); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js similarity index 78% rename from src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js rename to src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js index 9ab0b45f8c8f..40887ddee697 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js @@ -3,7 +3,18 @@ import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCa import PDFView from '@components/PDFView'; import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes'; -function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarousel, onPress, onScaleChanged: onScaleChangedProp, onToggleKeyboard, onLoadComplete, errorLabelStyles, style}) { +function BaseAttachmentViewPdf({ + file, + encryptedSourceUrl, + isFocused, + isUsedInCarousel, + onPress, + onScaleChanged: onScaleChangedProp, + onToggleKeyboard, + onLoadComplete, + errorLabelStyles, + style, +}) { const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); useEffect(() => { @@ -16,7 +27,7 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse const onScaleChanged = useCallback( (scale) => { - onScaleChangedProp(); + onScaleChangedProp(scale); // When a pdf is shown in a carousel, we want to disable the pager scroll when the pdf is zoomed in if (isUsedInCarousel) { @@ -49,7 +60,8 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse ); } -AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; -AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; +BaseAttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; +BaseAttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; +BaseAttachmentViewPdf.displayName = 'BaseAttachmentViewPdf'; -export default memo(AttachmentViewPdf); +export default memo(BaseAttachmentViewPdf); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js new file mode 100644 index 000000000000..46afd23daa4c --- /dev/null +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js @@ -0,0 +1,68 @@ +import React, {memo, useCallback, useContext} from 'react'; +import {StyleSheet, View} from 'react-native'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import Animated, {useSharedValue} from 'react-native-reanimated'; +import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import styles from '@styles/styles'; +import BaseAttachmentViewPdf from './BaseAttachmentViewPdf'; +import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes'; + +function AttachmentViewPdf(props) { + const {onScaleChanged, ...restProps} = props; + const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); + const scaleRef = useSharedValue(1); + const offsetX = useSharedValue(0); + const offsetY = useSharedValue(0); + + const Pan = Gesture.Pan() + .manualActivation(true) + .onTouchesMove((evt) => { + if (offsetX.value !== 0 && offsetY.value !== 0) { + // if the value of X is greater than Y and the pdf is not zoomed in, + // enable the pager scroll so that the user + // can swipe to the next attachment otherwise disable it. + if (Math.abs(evt.allTouches[0].absoluteX - offsetX.value) > Math.abs(evt.allTouches[0].absoluteY - offsetY.value) && scaleRef.value === 1) { + attachmentCarouselPagerContext.shouldPagerScroll.value = true; + } else { + attachmentCarouselPagerContext.shouldPagerScroll.value = false; + } + } + offsetX.value = evt.allTouches[0].absoluteX; + offsetY.value = evt.allTouches[0].absoluteY; + }); + + const updateScale = useCallback( + (scale) => { + scaleRef.value = scale; + }, + [scaleRef], + ); + + return ( + + + + { + updateScale(scale); + onScaleChanged(); + }} + /> + + + + ); +} + +AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; +AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; + +export default memo(AttachmentViewPdf); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js new file mode 100644 index 000000000000..103ff292760f --- /dev/null +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js @@ -0,0 +1,17 @@ +import React, {memo} from 'react'; +import BaseAttachmentViewPdf from './BaseAttachmentViewPdf'; +import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes'; + +function AttachmentViewPdf(props) { + return ( + + ); +} + +AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; +AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; + +export default memo(AttachmentViewPdf); diff --git a/src/components/Avatar.js b/src/components/Avatar.js index 2a52862faafd..ee42f0c03e30 100644 --- a/src/components/Avatar.js +++ b/src/components/Avatar.js @@ -94,10 +94,7 @@ function Avatar(props) { const avatarTestId = imageError ? 'AvatarIcon' : fallbackAvatarTestID; return ( - + {_.isFunction(props.source) || _.isNumber(props.source) || (imageError && (_.isFunction(fallbackAvatar) || _.isNumber(fallbackAvatar))) ? ( ); } + Avatar.defaultProps = defaultProps; Avatar.propTypes = propTypes; +Avatar.displayName = 'Avatar'; + export default Avatar; diff --git a/src/components/AvatarCropModal/AvatarCropModal.js b/src/components/AvatarCropModal/AvatarCropModal.js index f83c7a89a40e..106ca5750a22 100644 --- a/src/components/AvatarCropModal/AvatarCropModal.js +++ b/src/components/AvatarCropModal/AvatarCropModal.js @@ -410,7 +410,7 @@ function AvatarCropModal(props) { onLayout={initializeSliderContainer} onPressIn={(e) => runOnUI(sliderOnPress)(e.nativeEvent.locationX)} accessibilityLabel="slider" - accessibilityRole={CONST.ACCESSIBILITY_ROLE.ADJUSTABLE} + role={CONST.ACCESSIBILITY_ROLE.ADJUSTABLE} > showActorDetails(props.report, props.shouldEnableDetailPageNavigation)} accessibilityLabel={title} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} > {shouldShowSubscriptAvatar ? ( ReportUtils.navigateToDetailsPage(props.report)} style={[styles.flexRow, styles.alignItemsCenter, styles.flex1]} accessibilityLabel={title} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} > {headerView} diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 826423a5b642..ac03a2f5fbcf 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -265,7 +265,7 @@ class AvatarWithImagePicker extends React.Component { this.setState((prev) => ({isMenuVisible: !prev.isMenuVisible}))} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={this.props.translate('avatarWithImagePicker.editImage')} disabled={this.state.isAvatarCropModalOpen} ref={this.anchorRef} diff --git a/src/components/Badge.js b/src/components/Badge.js index 0a6b72201655..49b330ae37b2 100644 --- a/src/components/Badge.js +++ b/src/components/Badge.js @@ -59,8 +59,9 @@ function Badge(props) { diff --git a/src/components/BigNumberPad.js b/src/components/BigNumberPad.js index ecbde3a5afe6..451b8fc3e0bf 100644 --- a/src/components/BigNumberPad.js +++ b/src/components/BigNumberPad.js @@ -16,14 +16,14 @@ const propTypes = { longPressHandlerStateChanged: PropTypes.func, /** Used to locate this view from native classes. */ - nativeID: PropTypes.string, + id: PropTypes.string, ...withLocalizePropTypes, }; const defaultProps = { longPressHandlerStateChanged: () => {}, - nativeID: 'numPadView', + id: 'numPadView', }; const padNumbers = [ @@ -59,7 +59,7 @@ function BigNumberPad(props) { return ( {_.map(padNumbers, (row, rowIndex) => ( { - if (!validateSubmitShortcut(this.props.isFocused, this.props.isDisabled, this.props.isLoading, e)) { - return; - } - this.props.onPress(); - }, - shortcutConfig.descriptionKey, - shortcutConfig.modifiers, - true, - this.props.allowBubble, - this.props.enterKeyEventListenerPriority, - false, - ); - } - - componentWillUnmount() { - // Cleanup event listeners - if (!this.unsubscribe) { - return; - } - this.unsubscribe(); - } - - renderContent() { - if (this.props.children) { - return this.props.children; +function Button({ + allowBubble, + text, + shouldShowRightIcon, + + icon, + iconRight, + iconFill, + iconStyles, + iconRightStyles, + + small, + large, + medium, + + isLoading, + isDisabled, + + onPress, + onLongPress, + onPressIn, + onPressOut, + onMouseDown, + + pressOnEnter, + enterKeyEventListenerPriority, + + style, + innerStyles, + textStyles, + + shouldUseDefaultHover, + success, + danger, + children, + + shouldRemoveRightBorderRadius, + shouldRemoveLeftBorderRadius, + shouldEnableHapticFeedback, + + id, + accessibilityLabel, + forwardedRef, +}) { + const isFocused = useIsFocused(); + + const keyboardShortcutCallback = useCallback( + (event) => { + if (!validateSubmitShortcut(isFocused, isDisabled, isLoading, event)) { + return; + } + onPress(); + }, + [isDisabled, isFocused, isLoading, onPress], + ); + + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, keyboardShortcutCallback, { + isActive: pressOnEnter, + shouldBubble: allowBubble, + priority: enterKeyEventListenerPriority, + shouldPreventDefault: false, + }); + + const renderContent = () => { + if (children) { + return children; } const textComponent = ( - {this.props.text} + {text} ); - if (this.props.icon || this.props.shouldShowRightIcon) { + if (icon || shouldShowRightIcon) { return ( - {this.props.icon && ( - + {icon && ( + )} {textComponent} - {this.props.shouldShowRightIcon && ( - + {shouldShowRightIcon && ( + )} @@ -258,87 +276,85 @@ class Button extends Component { } return textComponent; - } - - render() { - return ( - { - if (e && e.type === 'click') { - e.currentTarget.blur(); - } - - if (this.props.shouldEnableHapticFeedback) { - HapticFeedback.press(); - } - return this.props.onPress(e); - }} - onLongPress={(e) => { - if (this.props.shouldEnableHapticFeedback) { - HapticFeedback.longPress(); - } - this.props.onLongPress(e); - }} - onPressIn={this.props.onPressIn} - onPressOut={this.props.onPressOut} - onMouseDown={this.props.onMouseDown} - disabled={this.props.isLoading || this.props.isDisabled} - wrapperStyle={[ - this.props.isDisabled ? {...styles.cursorDisabled, ...styles.noSelect} : {}, - styles.buttonContainer, - this.props.shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, - this.props.shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, - ...StyleUtils.parseStyleAsArray(this.props.style), - ]} - style={[ - styles.button, - this.props.small ? styles.buttonSmall : undefined, - this.props.medium ? styles.buttonMedium : undefined, - this.props.large ? styles.buttonLarge : undefined, - this.props.success ? styles.buttonSuccess : undefined, - this.props.danger ? styles.buttonDanger : undefined, - this.props.isDisabled && (this.props.success || this.props.danger) ? styles.buttonOpacityDisabled : undefined, - this.props.isDisabled && !this.props.danger && !this.props.success ? styles.buttonDisabled : undefined, - this.props.shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, - this.props.shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, - this.props.icon || this.props.shouldShowRightIcon ? styles.alignItemsStretch : undefined, - ...this.props.innerStyles, - ]} - hoverStyle={[ - this.props.shouldUseDefaultHover && !this.props.isDisabled ? styles.buttonDefaultHovered : undefined, - this.props.success && !this.props.isDisabled ? styles.buttonSuccessHovered : undefined, - this.props.danger && !this.props.isDisabled ? styles.buttonDangerHovered : undefined, - ]} - nativeID={this.props.nativeID} - accessibilityLabel={this.props.accessibilityLabel} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - hoverDimmingValue={1} - > - {this.renderContent()} - {this.props.isLoading && ( - - )} - - ); - } + }; + + return ( + { + if (event && event.type === 'click') { + event.currentTarget.blur(); + } + + if (shouldEnableHapticFeedback) { + HapticFeedback.press(); + } + return onPress(event); + }} + onLongPress={(event) => { + if (shouldEnableHapticFeedback) { + HapticFeedback.longPress(); + } + onLongPress(event); + }} + onPressIn={onPressIn} + onPressOut={onPressOut} + onMouseDown={onMouseDown} + disabled={isLoading || isDisabled} + wrapperStyle={[ + isDisabled ? {...styles.cursorDisabled, ...styles.noSelect} : {}, + styles.buttonContainer, + shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, + shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, + ...StyleUtils.parseStyleAsArray(style), + ]} + style={[ + styles.button, + small ? styles.buttonSmall : undefined, + medium ? styles.buttonMedium : undefined, + large ? styles.buttonLarge : undefined, + success ? styles.buttonSuccess : undefined, + danger ? styles.buttonDanger : undefined, + isDisabled && (success || danger) ? styles.buttonOpacityDisabled : undefined, + isDisabled && !danger && !success ? styles.buttonDisabled : undefined, + shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, + shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, + icon || shouldShowRightIcon ? styles.alignItemsStretch : undefined, + ...innerStyles, + ]} + hoverStyle={[ + shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined, + success && !isDisabled ? styles.buttonSuccessHovered : undefined, + danger && !isDisabled ? styles.buttonDangerHovered : undefined, + ]} + id={id} + accessibilityLabel={accessibilityLabel} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} + hoverDimmingValue={1} + > + {renderContent()} + {isLoading && ( + + )} + + ); } Button.propTypes = propTypes; Button.defaultProps = defaultProps; +Button.displayName = 'Button'; + +const ButtonWithRef = React.forwardRef((props, ref) => ( +